mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-07-26 11:59:14 +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:
@@ -13,3 +13,30 @@ run = ["npm", "run", "start"]
|
||||
[[ports]]
|
||||
localPort = 5000
|
||||
externalPort = 80
|
||||
|
||||
[workflows]
|
||||
runButton = "Project"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Project"
|
||||
mode = "parallel"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "Start application"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Start application"
|
||||
author = "agent"
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
agentRequireRestartOnSave = false
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "packager.installForAll"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "npm run dev"
|
||||
waitForPort = 5000
|
||||
|
||||
+27
-7
@@ -1,15 +1,35 @@
|
||||
import { Switch, Route } from "wouter";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import AuthPage from "@/pages/auth-page";
|
||||
import DashboardPage from "@/pages/dashboard-page";
|
||||
import UsersPage from "@/pages/users-page";
|
||||
import GroupsPage from "@/pages/groups-page";
|
||||
import OUsPage from "@/pages/ous-page";
|
||||
import ComputersPage from "@/pages/computers-page";
|
||||
import DomainsPage from "@/pages/domains-page";
|
||||
import ApiTokensPage from "@/pages/api-tokens-page";
|
||||
import LdapConnectionsPage from "@/pages/ldap-connections-page";
|
||||
import SettingsPage from "@/pages/settings-page";
|
||||
import UserManagementPage from "@/pages/user-management-page";
|
||||
import { ProtectedRoute } from "./lib/protected-route";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
{/* Add pages below */}
|
||||
{/* <Route path="/" component={Home}/> */}
|
||||
{/* Fallback to 404 */}
|
||||
<Route path="/auth">
|
||||
<AuthPage />
|
||||
</Route>
|
||||
<ProtectedRoute path="/" component={DashboardPage} />
|
||||
<ProtectedRoute path="/users" component={UsersPage} />
|
||||
<ProtectedRoute path="/groups" component={GroupsPage} />
|
||||
<ProtectedRoute path="/organizational-units" component={OUsPage} />
|
||||
<ProtectedRoute path="/computers" component={ComputersPage} />
|
||||
<ProtectedRoute path="/domains" component={DomainsPage} />
|
||||
<ProtectedRoute path="/api-tokens" component={ApiTokensPage} />
|
||||
<ProtectedRoute path="/ldap-connections" component={LdapConnectionsPage} />
|
||||
<ProtectedRoute path="/settings" component={SettingsPage} />
|
||||
<ProtectedRoute path="/user-management" component={UserManagementPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
@@ -17,10 +37,10 @@ function Router() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<>
|
||||
<Router />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
UseMutationResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { insertUserSchema, User as SelectUser, InsertUser } from "@shared/schema";
|
||||
import { getQueryFn, apiRequest, queryClient } from "../lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type AuthContextType = {
|
||||
user: SelectUser | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
loginMutation: UseMutationResult<SelectUser, Error, LoginData>;
|
||||
logoutMutation: UseMutationResult<void, Error, void>;
|
||||
registerMutation: UseMutationResult<SelectUser, Error, InsertUser>;
|
||||
};
|
||||
|
||||
type LoginData = Pick<InsertUser, "username" | "password">;
|
||||
|
||||
export const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
data: user,
|
||||
error,
|
||||
isLoading,
|
||||
} = useQuery<SelectUser | null, Error>({
|
||||
queryKey: ["/api/user"],
|
||||
queryFn: getQueryFn({ on401: "returnNull" }),
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (credentials: LoginData) => {
|
||||
const res = await apiRequest("POST", "/api/login", credentials);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (user: SelectUser) => {
|
||||
queryClient.setQueryData(["/api/user"], user);
|
||||
toast({
|
||||
title: "Login successful",
|
||||
description: `Welcome back, ${user.username}!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Login failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (credentials: InsertUser) => {
|
||||
const res = await apiRequest("POST", "/api/register", credentials);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (user: SelectUser) => {
|
||||
queryClient.setQueryData(["/api/user"], user);
|
||||
toast({
|
||||
title: "Registration successful",
|
||||
description: `Welcome, ${user.username}!`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Registration failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiRequest("POST", "/api/logout");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(["/api/user"], null);
|
||||
toast({
|
||||
title: "Logged out",
|
||||
description: "You have been successfully logged out.",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Logout failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user: user ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
loginMutation,
|
||||
logoutMutation,
|
||||
registerMutation,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
export function useMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
+106
-3
@@ -3,11 +3,114 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 96%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--chart-1: 221.2 83.2% 53.3%;
|
||||
--chart-2: 292.2 84.1% 60.6%;
|
||||
--chart-3: 31.5 91.7% 48.8%;
|
||||
--chart-4: 182.9 75.4% 52.7%;
|
||||
--chart-5: 355.7 100% 54.7%;
|
||||
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 240 10% 3.9%;
|
||||
--sidebar-primary: 221.2 83.2% 53.3%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 240 5.9% 90%;
|
||||
--sidebar-ring: 142.1 76.2% 36.3%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 0 0% 95%;
|
||||
|
||||
--card: 24 9.8% 10%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 142.4 71.8% 29.2%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
@apply font-sans antialiased bg-background text-foreground;
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-item.active {
|
||||
@apply bg-opacity-10 bg-primary text-primary border-l-2 border-primary;
|
||||
}
|
||||
|
||||
.drawer-item:hover:not(.active) {
|
||||
@apply bg-muted;
|
||||
}
|
||||
|
||||
.material-card {
|
||||
@apply bg-card rounded shadow-sm hover:shadow transition-shadow duration-200;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center rounded-full px-2 py-1 text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-badge-connected {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.status-badge-disconnected {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
Menu,
|
||||
Bell,
|
||||
HelpCircle,
|
||||
User,
|
||||
Settings,
|
||||
Home,
|
||||
Users,
|
||||
UserPlus,
|
||||
FolderClosed,
|
||||
Monitor,
|
||||
Globe,
|
||||
Key,
|
||||
Server,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
|
||||
type MenuItem = {
|
||||
title: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
type MenuSection = {
|
||||
title: string;
|
||||
items: MenuItem[];
|
||||
};
|
||||
|
||||
const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: "Active Directory",
|
||||
items: [
|
||||
{ title: "Users", path: "/users", icon: <Users className="h-4 w-4" /> },
|
||||
{ title: "Groups", path: "/groups", icon: <UserPlus className="h-4 w-4" /> },
|
||||
{ title: "Organizational Units", path: "/organizational-units", icon: <FolderClosed className="h-4 w-4" /> },
|
||||
{ title: "Computers", path: "/computers", icon: <Monitor className="h-4 w-4" /> },
|
||||
{ title: "Domains", path: "/domains", icon: <Globe className="h-4 w-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Administration",
|
||||
items: [
|
||||
{ title: "API Tokens", path: "/api-tokens", icon: <Key className="h-4 w-4" /> },
|
||||
{ title: "LDAP Connections", path: "/ldap-connections", icon: <Server className="h-4 w-4" /> },
|
||||
{ title: "Settings", path: "/settings", icon: <Settings className="h-4 w-4" /> },
|
||||
{ title: "User Management", path: "/user-management", icon: <ShieldAlert className="h-4 w-4" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children, title, description }: DashboardLayoutProps) {
|
||||
const [location] = useLocation();
|
||||
const { user, logoutMutation } = useAuth();
|
||||
const isMobile = useMobile();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
|
||||
// Get user initials for avatar
|
||||
const getInitials = () => {
|
||||
if (!user) return "U";
|
||||
|
||||
if (user.fullName) {
|
||||
const nameParts = user.fullName.split(" ");
|
||||
if (nameParts.length > 1) {
|
||||
return `${nameParts[0][0]}${nameParts[nameParts.length - 1][0]}`.toUpperCase();
|
||||
}
|
||||
return nameParts[0][0].toUpperCase();
|
||||
}
|
||||
|
||||
return user.username[0].toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`w-64 h-full bg-white shadow-md z-10 flex-shrink-0 transition-all duration-300 ease-in-out ${
|
||||
sidebarOpen ? "" : "-ml-64"
|
||||
} ${isMobile ? "absolute" : "relative"}`}
|
||||
>
|
||||
<div className="h-16 flex items-center px-4 border-b">
|
||||
<Link href="/">
|
||||
<a className="font-medium text-lg text-primary">AD Management API</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto h-[calc(100%-64px)]">
|
||||
<div className="px-2 pt-4">
|
||||
<Link href="/">
|
||||
<a
|
||||
className={`drawer-item px-4 py-2 flex items-center space-x-3 rounded cursor-pointer ${
|
||||
location === "/" ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
{menuSections.map((section, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="mt-6 mb-2 px-4 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{section.title}
|
||||
</div>
|
||||
|
||||
{section.items.map((item, itemIdx) => (
|
||||
<Link href={item.path} key={itemIdx}>
|
||||
<a
|
||||
className={`drawer-item px-4 py-2 flex items-center space-x-3 rounded cursor-pointer ${
|
||||
location === item.path ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top App Bar */}
|
||||
<header className="h-16 bg-white shadow-sm flex items-center justify-between px-4 z-10">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="icon">
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center space-x-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-primary text-white">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm hidden md:inline-block">{user?.username}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto p-6 bg-background">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-medium text-gray-800">{title}</h1>
|
||||
{description && <p className="text-gray-600">{description}</p>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Redirect, Route } from "wouter";
|
||||
|
||||
export function ProtectedRoute({
|
||||
path,
|
||||
component: Component,
|
||||
}: {
|
||||
path: string;
|
||||
component: () => React.JSX.Element;
|
||||
}) {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Route path={path}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Route path={path}>
|
||||
<Redirect to="/auth" />
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Route path={path}>
|
||||
<Component />
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { AuthProvider } from "./hooks/use-auth";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { ApiToken } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { CreateTokenModal } from "@/components/modals/create-token-modal";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export default function ApiTokensPage() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [tokenToDelete, setTokenToDelete] = useState<ApiToken | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: tokens = [], isLoading } = useQuery<ApiToken[]>({
|
||||
queryKey: ["/api/tokens"],
|
||||
});
|
||||
|
||||
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 handleDeleteToken = (token: ApiToken) => {
|
||||
setTokenToDelete(token);
|
||||
};
|
||||
|
||||
const confirmDeleteToken = () => {
|
||||
if (tokenToDelete) {
|
||||
deleteTokenMutation.mutate(tokenToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
},
|
||||
{
|
||||
header: "Created",
|
||||
accessorKey: "createdAt",
|
||||
cell: (row: ApiToken) => row.createdAt ? format(new Date(row.createdAt), 'MMM dd, yyyy') : '',
|
||||
},
|
||||
{
|
||||
header: "Expires",
|
||||
accessorKey: "expiresAt",
|
||||
cell: (row: ApiToken) => row.expiresAt ? format(new Date(row.expiresAt), 'MMM dd, yyyy') : 'Never',
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
accessorKey: (row: ApiToken) => (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteToken(row);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="API Tokens" description="Manage API Tokens for accessing the AD Management API">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create and manage API tokens for accessing the AD Management API programmatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={tokens}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
searchable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(tokens.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { insertUserSchema } from "@shared/schema";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
// Login form schema
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
rememberMe: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
|
||||
// Registration form schema
|
||||
const registerSchema = insertUserSchema.extend({
|
||||
confirmPassword: z.string().min(1, "Password confirmation is required"),
|
||||
acceptTerms: z.boolean().refine(val => val === true, {
|
||||
message: "You must accept the terms and conditions",
|
||||
}),
|
||||
}).refine(data => data.password === data.confirmPassword, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type RegisterFormValues = z.infer<typeof registerSchema>;
|
||||
|
||||
export default function AuthPage() {
|
||||
const [location, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
// Check if user is already logged in
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// User is already logged in, redirect to home
|
||||
navigate('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking auth status:', error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
|
||||
// Login mutation
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (credentials: LoginFormValues) => {
|
||||
const res = await apiRequest("POST", "/api/login", credentials);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
toast({
|
||||
title: "Login successful",
|
||||
description: `Welcome back, ${user.username}!`,
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Login failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Register mutation
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (credentials: any) => {
|
||||
const res = await apiRequest("POST", "/api/register", credentials);
|
||||
return await res.json();
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
toast({
|
||||
title: "Registration successful",
|
||||
description: `Welcome, ${user.username}!`,
|
||||
});
|
||||
navigate('/');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Registration failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Login form
|
||||
const loginForm = useForm<LoginFormValues>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Register form
|
||||
const registerForm = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
fullName: "",
|
||||
acceptTerms: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle login form submission
|
||||
const onLoginSubmit = (data: LoginFormValues) => {
|
||||
const { username, password } = data;
|
||||
loginMutation.mutate({ username, password });
|
||||
};
|
||||
|
||||
// Handle register form submission
|
||||
const onRegisterSubmit = (data: RegisterFormValues) => {
|
||||
// Remove confirmPassword and acceptTerms which aren't part of the API request
|
||||
const { confirmPassword, acceptTerms, ...registerData } = data;
|
||||
registerMutation.mutate(registerData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<div className="flex flex-col justify-center flex-1 px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
|
||||
<div className="w-full max-w-sm mx-auto lg:w-96">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Active Directory Management API</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
A comprehensive solution for managing your Active Directory resources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="login">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="login">Login</TabsTrigger>
|
||||
<TabsTrigger value="register">Register</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="login">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Login to your account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to access the dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...loginForm}>
|
||||
<form onSubmit={loginForm.handleSubmit(onLoginSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your username"
|
||||
{...field}
|
||||
disabled={loginMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
disabled={loginMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<FormField
|
||||
control={loginForm.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={loginMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">
|
||||
Remember me
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button variant="link" className="text-sm p-0 h-auto" disabled={loginMutation.isPending}>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loginMutation.isPending}
|
||||
>
|
||||
{loginMutation.isPending ? "Logging in..." : "Log in"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="register">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create a new account</CardTitle>
|
||||
<CardDescription>
|
||||
Fill out the form to register a new account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...registerForm}>
|
||||
<form onSubmit={registerForm.handleSubmit(onRegisterSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Choose a username"
|
||||
{...field}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="fullName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your full name"
|
||||
{...field}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
{...field}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Create a password"
|
||||
{...field}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm your password"
|
||||
{...field}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={registerForm.control}
|
||||
name="acceptTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
I accept the terms and conditions
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={registerMutation.isPending}
|
||||
>
|
||||
{registerMutation.isPending ? "Creating account..." : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative hidden w-0 flex-1 lg:block">
|
||||
<div className="absolute inset-0 bg-primary flex items-center justify-center">
|
||||
<div className="max-w-lg p-8 text-white">
|
||||
<h2 className="text-4xl font-bold mb-6">Active Directory Management API</h2>
|
||||
<ul className="space-y-4 text-lg">
|
||||
<li className="flex items-center">
|
||||
<span className="mr-2 inline-block w-6 h-6 bg-white bg-opacity-20 rounded-full flex items-center justify-center text-sm">✓</span>
|
||||
Complete CRUD operations for AD resources
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="mr-2 inline-block w-6 h-6 bg-white bg-opacity-20 rounded-full flex items-center justify-center text-sm">✓</span>
|
||||
Comprehensive API with Swagger documentation
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="mr-2 inline-block w-6 h-6 bg-white bg-opacity-20 rounded-full flex items-center justify-center text-sm">✓</span>
|
||||
Flexible filtering and property selection
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="mr-2 inline-block w-6 h-6 bg-white bg-opacity-20 rounded-full flex items-center justify-center text-sm">✓</span>
|
||||
Secure API token management
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="mr-2 inline-block w-6 h-6 bg-white bg-opacity-20 rounded-full flex items-center justify-center text-sm">✓</span>
|
||||
Intuitive and responsive admin interface
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Plus, FileDown, FileUp } from "lucide-react";
|
||||
import { AdComputer, LdapConnection } from "@shared/schema";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function ComputersPage() {
|
||||
const [selectedConnection, setSelectedConnection] = useState<number | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: connections = [], isLoading: isLoadingConnections } = useQuery<LdapConnection[]>({
|
||||
queryKey: ["/api/ldap-connections"],
|
||||
});
|
||||
|
||||
const { data: computers = [], isLoading: isLoadingComputers } = useQuery<AdComputer[]>({
|
||||
queryKey: [
|
||||
`/api/connections/${selectedConnection}/ad-computers`,
|
||||
{ select: "id,name,distinguishedName,dnsHostName,operatingSystem,operatingSystemVersion,lastLogon,enabled" }
|
||||
],
|
||||
enabled: !!selectedConnection,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
},
|
||||
{
|
||||
header: "DNS Host Name",
|
||||
accessorKey: "dnsHostName",
|
||||
},
|
||||
{
|
||||
header: "Operating System",
|
||||
accessorKey: (row: AdComputer) =>
|
||||
row.operatingSystem ?
|
||||
`${row.operatingSystem}${row.operatingSystemVersion ? ` (${row.operatingSystemVersion})` : ''}` :
|
||||
'Unknown',
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "enabled",
|
||||
cell: (row: AdComputer) => (
|
||||
row.enabled ?
|
||||
<StatusBadge status="connected">Enabled</StatusBadge> :
|
||||
<StatusBadge status="disconnected">Disabled</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Last Logon",
|
||||
accessorKey: "lastLogon",
|
||||
cell: (row: AdComputer) => row.lastLogon ? format(new Date(row.lastLogon), 'MMM dd, yyyy') : 'Never',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Computers" description="Manage Active Directory Computers">
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{connections.map((connection) => (
|
||||
<Badge
|
||||
key={connection.id}
|
||||
variant={selectedConnection === connection.id ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedConnection(connection.id)}
|
||||
>
|
||||
{connection.name}
|
||||
</Badge>
|
||||
))}
|
||||
{connections.length === 0 && !isLoadingConnections && (
|
||||
<div className="text-muted-foreground">
|
||||
No LDAP connections available. Add a connection first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<FileUp className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Computer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={computers}
|
||||
columns={columns}
|
||||
isLoading={isLoadingComputers || isLoadingConnections}
|
||||
searchable
|
||||
filterable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(computers.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import StatsCard from "@/components/dashboard/stats-card";
|
||||
import ApiActivityCard from "@/components/dashboard/api-activity-card";
|
||||
import ApiTokensCard from "@/components/dashboard/api-tokens-card";
|
||||
import LdapConnectionsCard from "@/components/dashboard/ldap-connections-card";
|
||||
import ApiDocumentationCard from "@/components/dashboard/api-documentation-card";
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
FolderClosed,
|
||||
Monitor
|
||||
} from "lucide-react";
|
||||
import { LdapConnection, ApiToken } from "@shared/schema";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: connections = [] } = useQuery<LdapConnection[]>({
|
||||
queryKey: ["/api/ldap-connections"],
|
||||
});
|
||||
|
||||
const { data: tokens = [] } = useQuery<ApiToken[]>({
|
||||
queryKey: ["/api/tokens"],
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Dashboard"
|
||||
description="Active Directory Management API Overview"
|
||||
>
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Users"
|
||||
value={1284}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
iconBgColor="bg-blue-100"
|
||||
iconColor="text-primary"
|
||||
changeValue={4.75}
|
||||
changeType="increase"
|
||||
changePeriod="from last month"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Total Groups"
|
||||
value={87}
|
||||
icon={<UserPlus className="h-5 w-5" />}
|
||||
iconBgColor="bg-purple-100"
|
||||
iconColor="text-purple-600"
|
||||
changeValue={2.3}
|
||||
changeType="increase"
|
||||
changePeriod="from last month"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Organizational Units"
|
||||
value={32}
|
||||
icon={<FolderClosed className="h-5 w-5" />}
|
||||
iconBgColor="bg-amber-100"
|
||||
iconColor="text-amber-600"
|
||||
changeValue={0}
|
||||
changeType="nochange"
|
||||
changePeriod="from last month"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Total Computers"
|
||||
value={563}
|
||||
icon={<Monitor className="h-5 w-5" />}
|
||||
iconBgColor="bg-teal-100"
|
||||
iconColor="text-teal-600"
|
||||
changeValue={1.2}
|
||||
changeType="decrease"
|
||||
changePeriod="from last month"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* API Activity Card */}
|
||||
<div className="lg:col-span-2">
|
||||
<ApiActivityCard />
|
||||
</div>
|
||||
|
||||
{/* Active API Tokens */}
|
||||
<div>
|
||||
<ApiTokensCard tokens={tokens} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LDAP Connections */}
|
||||
<div className="mt-6">
|
||||
<LdapConnectionsCard connections={connections} />
|
||||
</div>
|
||||
|
||||
{/* API Documentation Preview */}
|
||||
<div className="mt-6">
|
||||
<ApiDocumentationCard />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, FileDown, FileUp } from "lucide-react";
|
||||
import { AdDomain, LdapConnection } from "@shared/schema";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function DomainsPage() {
|
||||
const [selectedConnection, setSelectedConnection] = useState<number | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: connections = [], isLoading: isLoadingConnections } = useQuery<LdapConnection[]>({
|
||||
queryKey: ["/api/ldap-connections"],
|
||||
});
|
||||
|
||||
const { data: domains = [], isLoading: isLoadingDomains } = useQuery<AdDomain[]>({
|
||||
queryKey: [
|
||||
`/api/connections/${selectedConnection}/ad-domains`,
|
||||
{ select: "id,name,distinguishedName,netBIOSName,forestName,domainFunctionality" }
|
||||
],
|
||||
enabled: !!selectedConnection,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Domain Name",
|
||||
accessorKey: "name",
|
||||
},
|
||||
{
|
||||
header: "NetBIOS Name",
|
||||
accessorKey: "netBIOSName",
|
||||
},
|
||||
{
|
||||
header: "Forest Name",
|
||||
accessorKey: "forestName",
|
||||
},
|
||||
{
|
||||
header: "Functionality Level",
|
||||
accessorKey: "domainFunctionality",
|
||||
},
|
||||
{
|
||||
header: "Distinguished Name",
|
||||
accessorKey: "distinguishedName",
|
||||
cell: (row: AdDomain) => (
|
||||
<div className="max-w-md truncate" title={row.distinguishedName}>
|
||||
{row.distinguishedName}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Domains" description="Manage Active Directory Domains">
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{connections.map((connection) => (
|
||||
<Badge
|
||||
key={connection.id}
|
||||
variant={selectedConnection === connection.id ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedConnection(connection.id)}
|
||||
>
|
||||
{connection.name}
|
||||
</Badge>
|
||||
))}
|
||||
{connections.length === 0 && !isLoadingConnections && (
|
||||
<div className="text-muted-foreground">
|
||||
No LDAP connections available. Add a connection first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<FileUp className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={domains}
|
||||
columns={columns}
|
||||
isLoading={isLoadingDomains || isLoadingConnections}
|
||||
searchable
|
||||
filterable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(domains.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, FileDown, FileUp } from "lucide-react";
|
||||
import { AdGroup, LdapConnection } from "@shared/schema";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function GroupsPage() {
|
||||
const [selectedConnection, setSelectedConnection] = useState<number | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: connections = [], isLoading: isLoadingConnections } = useQuery<LdapConnection[]>({
|
||||
queryKey: ["/api/ldap-connections"],
|
||||
});
|
||||
|
||||
const { data: groups = [], isLoading: isLoadingGroups } = useQuery<AdGroup[]>({
|
||||
queryKey: [
|
||||
`/api/connections/${selectedConnection}/ad-groups`,
|
||||
{ select: "id,sAMAccountName,distinguishedName,description,groupType" }
|
||||
],
|
||||
enabled: !!selectedConnection,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Group Name",
|
||||
accessorKey: "sAMAccountName",
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessorKey: "description",
|
||||
},
|
||||
{
|
||||
header: "Group Type",
|
||||
accessorKey: "groupType",
|
||||
},
|
||||
{
|
||||
header: "Distinguished Name",
|
||||
accessorKey: "distinguishedName",
|
||||
cell: (row: AdGroup) => (
|
||||
<div className="max-w-md truncate" title={row.distinguishedName}>
|
||||
{row.distinguishedName}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Groups" description="Manage Active Directory Groups">
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{connections.map((connection) => (
|
||||
<Badge
|
||||
key={connection.id}
|
||||
variant={selectedConnection === connection.id ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedConnection(connection.id)}
|
||||
>
|
||||
{connection.name}
|
||||
</Badge>
|
||||
))}
|
||||
{connections.length === 0 && !isLoadingConnections && (
|
||||
<div className="text-muted-foreground">
|
||||
No LDAP connections available. Add a connection first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<FileUp className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={groups}
|
||||
columns={columns}
|
||||
isLoading={isLoadingGroups || isLoadingConnections}
|
||||
searchable
|
||||
filterable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(groups.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
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 { format } from "date-fns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { AddLdapModal } from "@/components/modals/add-ldap-modal";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export default function LdapConnectionsPage() {
|
||||
const { toast } = useToast();
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [connectionToEdit, setConnectionToEdit] = useState<LdapConnection | null>(null);
|
||||
const [connectionToDelete, setConnectionToDelete] = useState<LdapConnection | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: connections = [], isLoading } = useQuery<LdapConnection[]>({
|
||||
queryKey: ["/api/ldap-connections"],
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Connection Name",
|
||||
accessorKey: "name",
|
||||
},
|
||||
{
|
||||
header: "Server",
|
||||
accessorKey: "server",
|
||||
},
|
||||
{
|
||||
header: "Domain",
|
||||
accessorKey: "domain",
|
||||
},
|
||||
{
|
||||
header: "Port",
|
||||
accessorKey: "port",
|
||||
},
|
||||
{
|
||||
header: "SSL",
|
||||
accessorKey: "useSSL",
|
||||
cell: (row: LdapConnection) => row.useSSL ? "Yes" : "No",
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "status",
|
||||
cell: (row: LdapConnection) => (
|
||||
<StatusBadge status={row.status === "connected" ? "connected" : "disconnected"}>
|
||||
{row.status === "connected" ? "Connected" : "Disconnected"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Last Connected",
|
||||
accessorKey: "lastConnected",
|
||||
cell: (row: LdapConnection) => row.lastConnected ? format(new Date(row.lastConnected), 'MMM dd, yyyy HH:mm') : 'Never',
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
accessorKey: (row: LdapConnection) => (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditConnection(row);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteConnection(row);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="LDAP Connections" description="Manage connections to Active Directory Servers">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set up and manage connections to your Active Directory servers.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => {
|
||||
setConnectionToEdit(null);
|
||||
setIsAddModalOpen(true);
|
||||
}}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Connection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={connections}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
searchable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(connections.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, FileDown, FileUp } from "lucide-react";
|
||||
import { AdOrgUnit, LdapConnection } from "@shared/schema";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function OUsPage() {
|
||||
const [selectedConnection, setSelectedConnection] = useState<number | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: connections = [], isLoading: isLoadingConnections } = useQuery<LdapConnection[]>({
|
||||
queryKey: ["/api/ldap-connections"],
|
||||
});
|
||||
|
||||
const { data: orgUnits = [], isLoading: isLoadingOUs } = useQuery<AdOrgUnit[]>({
|
||||
queryKey: [
|
||||
`/api/connections/${selectedConnection}/ad-org-units`,
|
||||
{ select: "id,name,distinguishedName,description" }
|
||||
],
|
||||
enabled: !!selectedConnection,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "name",
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessorKey: "description",
|
||||
},
|
||||
{
|
||||
header: "Distinguished Name",
|
||||
accessorKey: "distinguishedName",
|
||||
cell: (row: AdOrgUnit) => (
|
||||
<div className="max-w-md truncate" title={row.distinguishedName}>
|
||||
{row.distinguishedName}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Organizational Units" description="Manage Active Directory Organizational Units">
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{connections.map((connection) => (
|
||||
<Badge
|
||||
key={connection.id}
|
||||
variant={selectedConnection === connection.id ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedConnection(connection.id)}
|
||||
>
|
||||
{connection.name}
|
||||
</Badge>
|
||||
))}
|
||||
{connections.length === 0 && !isLoadingConnections && (
|
||||
<div className="text-muted-foreground">
|
||||
No LDAP connections available. Add a connection first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<FileUp className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add OU
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={orgUnits}
|
||||
columns={columns}
|
||||
isLoading={isLoadingOUs || isLoadingConnections}
|
||||
searchable
|
||||
filterable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(orgUnits.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<DashboardLayout title="Settings" description="Configure your AD Management API application">
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
<TabsTrigger value="api">API</TabsTrigger>
|
||||
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general">
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure general application settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="app-name">Application Name</Label>
|
||||
<Input id="app-name" defaultValue="Active Directory Management API" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Theme</Label>
|
||||
<RadioGroup defaultValue="light">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="light" id="light" />
|
||||
<Label htmlFor="light">Light</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="dark" id="dark" />
|
||||
<Label htmlFor="dark">Dark</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="system" id="system" />
|
||||
<Label htmlFor="system">System</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Language</Label>
|
||||
<Select defaultValue="en">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="fr">French</SelectItem>
|
||||
<SelectItem value="de">German</SelectItem>
|
||||
<SelectItem value="es">Spanish</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Date Format</Label>
|
||||
<Select defaultValue="mdy">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select date format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mdy">MM/DD/YYYY</SelectItem>
|
||||
<SelectItem value="dmy">DD/MM/YYYY</SelectItem>
|
||||
<SelectItem value="ymd">YYYY/MM/DD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Show Help Tips</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Show contextual help and tooltips throughout the application
|
||||
</div>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security">
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure security related settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="session-timeout">Session Timeout (minutes)</Label>
|
||||
<Input id="session-timeout" type="number" defaultValue="30" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Two-Factor Authentication</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Require two-factor authentication for all users
|
||||
</div>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>HTTPS Only</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Enforce HTTPS for all connections
|
||||
</div>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Password Policy</Label>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="min-length" className="text-sm font-normal">Minimum Length</Label>
|
||||
<Input id="min-length" type="number" defaultValue="8" className="w-20" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="require-uppercase" className="text-sm font-normal">Require Uppercase</Label>
|
||||
<Switch id="require-uppercase" defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="require-numbers" className="text-sm font-normal">Require Numbers</Label>
|
||||
<Switch id="require-numbers" defaultChecked />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="require-symbols" className="text-sm font-normal">Require Symbols</Label>
|
||||
<Switch id="require-symbols" defaultChecked />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api">
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure API related settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate-limit">Rate Limit (requests per minute)</Label>
|
||||
<Input id="rate-limit" type="number" defaultValue="100" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Default Token Expiration</Label>
|
||||
<Select defaultValue="30">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select expiration" />
|
||||
</SelectTrigger>
|
||||
<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="never">Never</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Enable API Documentation</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Make Swagger documentation publicly accessible
|
||||
</div>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>CORS</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Enable Cross-Origin Resource Sharing
|
||||
</div>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="allowed-origins">Allowed Origins</Label>
|
||||
<Input id="allowed-origins" defaultValue="*" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated list of allowed origins, or * for all origins
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure how notifications are handled
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Email Notifications</Label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Send notifications via email
|
||||
</div>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email-address">Email Address</Label>
|
||||
<Input id="email-address" type="email" defaultValue="admin@example.com" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Notification Types</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="notify-login" className="text-sm font-normal">Login Attempts</Label>
|
||||
<Switch id="notify-login" defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="notify-api-token" className="text-sm font-normal">API Token Creation/Deletion</Label>
|
||||
<Switch id="notify-api-token" defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="notify-ldap" className="text-sm font-normal">LDAP Connection Changes</Label>
|
||||
<Switch id="notify-ldap" defaultChecked />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="notify-user" className="text-sm font-normal">User Management</Label>
|
||||
<Switch id="notify-user" defaultChecked />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button variant="outline" className="mr-2">Cancel</Button>
|
||||
<Button>Save Changes</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Pencil, Trash2, Shield, ShieldOff } from "lucide-react";
|
||||
import { User } from "@shared/schema";
|
||||
import { format } from "date-fns";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { insertUserSchema } from "@shared/schema";
|
||||
|
||||
// User form schema
|
||||
const userFormSchema = insertUserSchema.extend({
|
||||
confirmPassword: z.string().optional(),
|
||||
id: z.number().optional(),
|
||||
}).refine(data => {
|
||||
// If password is provided, confirmPassword must match
|
||||
if (data.password && data.confirmPassword) {
|
||||
return data.password === data.confirmPassword;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: "Passwords do not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type UserFormValues = z.infer<typeof userFormSchema>;
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const { toast } = useToast();
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [userToEdit, setUserToEdit] = useState<User | null>(null);
|
||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userFormSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
fullName: "",
|
||||
role: "user",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ["/api/users"],
|
||||
});
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: async (values: UserFormValues) => {
|
||||
const { confirmPassword, id, ...userData } = values;
|
||||
if (id) {
|
||||
// Update existing user
|
||||
await apiRequest("PUT", `/api/users/${id}`, userData);
|
||||
} else {
|
||||
// Create new user
|
||||
await apiRequest("POST", "/api/register", userData);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: userToEdit ? "User updated" : "User created",
|
||||
description: userToEdit
|
||||
? "The user has been successfully updated."
|
||||
: "The user has been successfully created.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
setIsAddDialogOpen(false);
|
||||
setUserToEdit(null);
|
||||
form.reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to ${userToEdit ? "update" : "create"} user: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest("DELETE", `/api/users/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "User deleted",
|
||||
description: "The user has been successfully deleted.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
setUserToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to delete user: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddUser = () => {
|
||||
form.reset({
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: "",
|
||||
fullName: "",
|
||||
role: "user",
|
||||
});
|
||||
setUserToEdit(null);
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
form.reset({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
email: user.email || "",
|
||||
fullName: user.fullName || "",
|
||||
role: user.role || "user",
|
||||
});
|
||||
setUserToEdit(user);
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (user: User) => {
|
||||
setUserToDelete(user);
|
||||
};
|
||||
|
||||
const confirmDeleteUser = () => {
|
||||
if (userToDelete) {
|
||||
deleteUserMutation.mutate(userToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (values: UserFormValues) => {
|
||||
createUserMutation.mutate(values);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Username",
|
||||
accessorKey: "username",
|
||||
},
|
||||
{
|
||||
header: "Full Name",
|
||||
accessorKey: "fullName",
|
||||
},
|
||||
{
|
||||
header: "Email",
|
||||
accessorKey: "email",
|
||||
},
|
||||
{
|
||||
header: "Role",
|
||||
accessorKey: "role",
|
||||
cell: (row: User) => (
|
||||
<Badge variant={row.role === "admin" ? "default" : "secondary"}>
|
||||
{row.role || "user"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Created",
|
||||
accessorKey: "createdAt",
|
||||
cell: (row: User) => row.createdAt ? format(new Date(row.createdAt), 'MMM dd, yyyy') : '',
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
accessorKey: (row: User) => (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditUser(row);
|
||||
}}
|
||||
title="Edit user"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{row.role === "admin" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Make user regular instead of admin
|
||||
}}
|
||||
title="Remove admin privileges"
|
||||
>
|
||||
<ShieldOff className="h-4 w-4 text-amber-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Make user admin
|
||||
}}
|
||||
title="Make admin"
|
||||
>
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteUser(row);
|
||||
}}
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="User Management" description="Manage application users and permissions">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage users who can access the AD Management admin interface.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddUser}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
searchable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(users.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{userToEdit ? "Edit User" : "Add New User"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{userToEdit
|
||||
? "Update user information and permissions."
|
||||
: "Fill in the details to create a new user."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter username"
|
||||
{...field}
|
||||
disabled={!!userToEdit}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fullName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter full name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<FormControl>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
{...field}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{userToEdit ? "New Password (optional)" : "Password"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={userToEdit ? "Leave blank to keep current" : "Enter password"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{userToEdit ? "Confirm New Password" : "Confirm Password"}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsAddDialogOpen(false);
|
||||
setUserToEdit(null);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createUserMutation.isPending}>
|
||||
{createUserMutation.isPending
|
||||
? "Saving..."
|
||||
: userToEdit ? "Update User" : "Create User"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!userToDelete} onOpenChange={() => setUserToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete User</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the user "{userToDelete?.username}"?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDeleteUser}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DashboardLayout } from "@/layouts/dashboard-layout";
|
||||
import { DataTable } from "@/components/ui/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Plus, FileDown, FileUp } from "lucide-react";
|
||||
import { AdUser, LdapConnection } from "@shared/schema";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function UsersPage() {
|
||||
const [selectedConnection, setSelectedConnection] = useState<number | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: connections = [], isLoading: isLoadingConnections } = useQuery<LdapConnection[]>({
|
||||
queryKey: ["/api/ldap-connections"],
|
||||
});
|
||||
|
||||
const { data: users = [], isLoading: isLoadingUsers } = useQuery<AdUser[]>({
|
||||
queryKey: [
|
||||
`/api/connections/${selectedConnection}/ad-users`,
|
||||
{ select: "id,sAMAccountName,distinguishedName,givenName,surname,email,enabled,lastLogon" }
|
||||
],
|
||||
enabled: !!selectedConnection,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
header: "Username",
|
||||
accessorKey: "sAMAccountName",
|
||||
},
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: (row: AdUser) =>
|
||||
(row.givenName && row.surname)
|
||||
? `${row.givenName} ${row.surname}`
|
||||
: row.displayName || row.sAMAccountName,
|
||||
},
|
||||
{
|
||||
header: "Email",
|
||||
accessorKey: "email",
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "enabled",
|
||||
cell: (row: AdUser) => (
|
||||
row.enabled ?
|
||||
<StatusBadge status="connected">Enabled</StatusBadge> :
|
||||
<StatusBadge status="disconnected">Disabled</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Last Logon",
|
||||
accessorKey: "lastLogon",
|
||||
cell: (row: AdUser) => row.lastLogon ? format(new Date(row.lastLogon), 'MMM dd, yyyy') : 'Never',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Users" description="Manage Active Directory Users">
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{connections.map((connection) => (
|
||||
<Badge
|
||||
key={connection.id}
|
||||
variant={selectedConnection === connection.id ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedConnection(connection.id)}
|
||||
>
|
||||
{connection.name}
|
||||
</Badge>
|
||||
))}
|
||||
{connections.length === 0 && !isLoadingConnections && (
|
||||
<div className="text-muted-foreground">
|
||||
No LDAP connections available. Add a connection first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<FileUp className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
isLoading={isLoadingUsers || isLoadingConnections}
|
||||
searchable
|
||||
filterable
|
||||
pagination={{
|
||||
pageIndex,
|
||||
pageSize,
|
||||
pageCount: Math.ceil(users.length / pageSize),
|
||||
onPageChange: setPageIndex,
|
||||
onPageSizeChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Generated
+458
-1
@@ -53,9 +53,11 @@
|
||||
"express-session": "^1.18.1",
|
||||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.2.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -64,6 +66,8 @@
|
||||
"react-icons": "^5.4.0",
|
||||
"react-resizable-panels": "^2.1.4",
|
||||
"recharts": "^2.13.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.0",
|
||||
@@ -125,6 +129,50 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
|
||||
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.6",
|
||||
"call-me-maybe": "^1.0.1",
|
||||
"js-yaml": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/openapi-schemas": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
|
||||
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-methods": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
|
||||
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@apidevtools/swagger-parser": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "^9.0.6",
|
||||
"@apidevtools/openapi-schemas": "^2.0.4",
|
||||
"@apidevtools/swagger-methods": "^3.0.2",
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"call-me-maybe": "^1.0.1",
|
||||
"z-schema": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openapi-types": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.26.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
|
||||
@@ -1384,6 +1432,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@neondatabase/serverless": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.10.4.tgz",
|
||||
@@ -3113,6 +3167,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.33.20",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.33.20.tgz",
|
||||
@@ -3366,6 +3427,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
@@ -3583,6 +3650,12 @@
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz",
|
||||
@@ -3750,6 +3823,12 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -3799,6 +3878,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-me-maybe": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
|
||||
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -3944,6 +4029,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/connect-pg-simple": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
|
||||
@@ -4253,6 +4344,18 @@
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
@@ -4850,6 +4953,15 @@
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -4997,6 +5109,15 @@
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -5288,6 +5409,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -5492,6 +5619,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -5637,6 +5775,18 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
|
||||
@@ -5663,6 +5813,61 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
@@ -5691,11 +5896,54 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
|
||||
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -5705,6 +5953,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.mergewith": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
|
||||
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -6040,6 +6300,22 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/openapi-types": {
|
||||
"version": "12.1.3",
|
||||
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
|
||||
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -6073,6 +6349,16 @@
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-local": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
|
||||
@@ -6092,6 +6378,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -7352,6 +7647,123 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc": {
|
||||
"version": "6.2.8",
|
||||
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
|
||||
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "6.2.0",
|
||||
"doctrine": "3.0.0",
|
||||
"glob": "7.1.6",
|
||||
"lodash.mergewith": "^4.6.2",
|
||||
"swagger-parser": "^10.0.3",
|
||||
"yaml": "2.0.0-1"
|
||||
},
|
||||
"bin": {
|
||||
"swagger-jsdoc": "bin/swagger-jsdoc.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc/node_modules/commander": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
|
||||
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc/node_modules/glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-jsdoc/node_modules/yaml": {
|
||||
"version": "2.0.0-1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
|
||||
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-parser": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
|
||||
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "10.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.20.7",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.20.7.tgz",
|
||||
"integrity": "sha512-gLpb1wrWinUwMFKfSvDYsIlCyGQSryftzi6uWc9Qo98zO3mFT6oHOqmDUu5OoahvepuS6HGTe/3MsGUCVtpLig==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-express": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
|
||||
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"swagger-ui-dist": ">=5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= v0.10.32"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">=4.0.0 || >=5.0.0-beta"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz",
|
||||
@@ -8088,6 +8500,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz",
|
||||
"integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -8742,6 +9163,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
@@ -8791,6 +9218,36 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/z-schema": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
|
||||
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"validator": "^13.7.0"
|
||||
},
|
||||
"bin": {
|
||||
"z-schema": "bin/z-schema"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"commander": "^9.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/z-schema/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.23.8",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
||||
|
||||
@@ -55,9 +55,11 @@
|
||||
"express-session": "^1.18.1",
|
||||
"framer-motion": "^11.13.1",
|
||||
"input-otp": "^1.2.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"memorystore": "^1.6.7",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -66,6 +68,8 @@
|
||||
"react-icons": "^5.4.0",
|
||||
"react-resizable-panels": "^2.1.4",
|
||||
"recharts": "^2.13.0",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.0",
|
||||
|
||||
+115
-80
@@ -1,12 +1,13 @@
|
||||
import passport from "passport";
|
||||
import { Strategy as LocalStrategy } from "passport-local";
|
||||
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
|
||||
import { Express } from "express";
|
||||
import session from "express-session";
|
||||
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
||||
import { promisify } from "util";
|
||||
import { storage } from "./storage";
|
||||
import { User as SelectUser } from "@shared/schema";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { storage } from "./storage";
|
||||
import { User as SelectUser, loginSchema } from "@shared/schema";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
@@ -15,14 +16,16 @@ declare global {
|
||||
}
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "super-secret-key-change-in-production";
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || "session-secret-change-in-production";
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
async function hashPassword(password: string) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const buf = (await scryptAsync(password, salt, 64)) as Buffer;
|
||||
return `${buf.toString("hex")}.${salt}`;
|
||||
}
|
||||
|
||||
export async function comparePasswords(supplied: string, stored: string) {
|
||||
async function comparePasswords(supplied: string, stored: string) {
|
||||
const [hashed, salt] = stored.split(".");
|
||||
const hashedBuf = Buffer.from(hashed, "hex");
|
||||
const suppliedBuf = (await scryptAsync(supplied, salt, 64)) as Buffer;
|
||||
@@ -30,18 +33,15 @@ export async function comparePasswords(supplied: string, stored: string) {
|
||||
}
|
||||
|
||||
export function setupAuth(app: Express) {
|
||||
const jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret_key_change_in_production';
|
||||
const sessionSecret = process.env.SESSION_SECRET || 'default_session_secret_key_change_in_production';
|
||||
|
||||
const sessionSettings: session.SessionOptions = {
|
||||
secret: sessionSecret,
|
||||
secret: SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
store: storage.sessionStore,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
}
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
},
|
||||
};
|
||||
|
||||
app.set("trust proxy", 1);
|
||||
@@ -49,21 +49,42 @@ export function setupAuth(app: Express) {
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
// Local strategy for username/password authentication
|
||||
passport.use(
|
||||
new LocalStrategy(async (username, password, done) => {
|
||||
try {
|
||||
const user = await storage.getUserByUsername(username);
|
||||
if (!user || !(await comparePasswords(password, user.password))) {
|
||||
return done(null, false);
|
||||
} else {
|
||||
return done(null, user);
|
||||
return done(null, false, { message: "Invalid username or password" });
|
||||
}
|
||||
return done(null, user);
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// JWT strategy for API token authentication
|
||||
passport.use(
|
||||
new JwtStrategy(
|
||||
{
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: JWT_SECRET,
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
const user = await storage.getUser(payload.sub);
|
||||
if (!user) {
|
||||
return done(null, false, { message: "User not found" });
|
||||
}
|
||||
return done(null, user);
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
passport.serializeUser((user, done) => done(null, user.id));
|
||||
passport.deserializeUser(async (id: number, done) => {
|
||||
try {
|
||||
@@ -74,102 +95,116 @@ export function setupAuth(app: Express) {
|
||||
}
|
||||
});
|
||||
|
||||
// Registration endpoint
|
||||
app.post("/api/register", async (req, res, next) => {
|
||||
try {
|
||||
const existingUser = await storage.getUserByUsername(req.body.username);
|
||||
const validationResult = loginSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
return res.status(400).json({ message: "Invalid input", errors: validationResult.error.errors });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
const existingUser = await storage.getUserByUsername(username);
|
||||
if (existingUser) {
|
||||
return res.status(400).json({ message: "Username already exists" });
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(req.body.password);
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const user = await storage.createUser({
|
||||
...req.body,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
email: req.body.email,
|
||||
fullName: req.body.fullName,
|
||||
role: req.body.role || "user",
|
||||
});
|
||||
|
||||
// Log this activity
|
||||
await storage.createActivityLog({
|
||||
action: 'Create',
|
||||
resource: user.username,
|
||||
resourceType: 'User',
|
||||
userId: null,
|
||||
username: 'System',
|
||||
status: 'Success',
|
||||
details: { id: user.id }
|
||||
});
|
||||
// Remove password from response
|
||||
const userResponse = { ...user, password: undefined };
|
||||
|
||||
req.login(user, (err) => {
|
||||
if (err) return next(err);
|
||||
|
||||
// Don't send password back
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
res.status(201).json(userWithoutPassword);
|
||||
res.status(201).json(userResponse);
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/login", passport.authenticate("local"), (req, res) => {
|
||||
// Don't send password back
|
||||
const { password, ...userWithoutPassword } = req.user as SelectUser;
|
||||
res.status(200).json(userWithoutPassword);
|
||||
// Login endpoint
|
||||
app.post("/api/login", (req, res, next) => {
|
||||
passport.authenticate("local", (err, user, info) => {
|
||||
if (err) return next(err);
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: info?.message || "Authentication failed" });
|
||||
}
|
||||
req.login(user, (loginErr) => {
|
||||
if (loginErr) return next(loginErr);
|
||||
// Remove password from response
|
||||
const userResponse = { ...user, password: undefined };
|
||||
res.json(userResponse);
|
||||
});
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
// Logout endpoint
|
||||
app.post("/api/logout", (req, res, next) => {
|
||||
req.logout((err) => {
|
||||
if (err) return next(err);
|
||||
res.sendStatus(200);
|
||||
req.session.destroy((sessionErr) => {
|
||||
if (sessionErr) return next(sessionErr);
|
||||
res.clearCookie("connect.sid");
|
||||
res.status(200).json({ message: "Logged out successfully" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Get current user endpoint
|
||||
app.get("/api/user", (req, res) => {
|
||||
if (!req.isAuthenticated()) return res.sendStatus(401);
|
||||
|
||||
// Don't send password back
|
||||
const { password, ...userWithoutPassword } = req.user as SelectUser;
|
||||
res.json(userWithoutPassword);
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
// Remove password from response
|
||||
const userResponse = { ...req.user, password: undefined };
|
||||
res.json(userResponse);
|
||||
});
|
||||
|
||||
// Middleware to verify API token
|
||||
const verifyApiToken = async (req: any, res: any, next: any) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'API token is required' });
|
||||
// Generate API token endpoint
|
||||
app.post("/api/tokens", (req, res, next) => {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if token exists in storage
|
||||
const apiToken = await storage.getApiTokenByToken(token);
|
||||
|
||||
if (!apiToken) {
|
||||
return res.status(401).json({ message: 'Invalid API token' });
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (apiToken.expiresAt && new Date(apiToken.expiresAt) < new Date()) {
|
||||
return res.status(401).json({ message: 'API token has expired' });
|
||||
}
|
||||
|
||||
// Update the last used timestamp
|
||||
await storage.updateApiToken(apiToken.id, { lastUsedAt: new Date() });
|
||||
|
||||
// Get the user associated with this token
|
||||
const user = await storage.getUser(apiToken.userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'User associated with token not found' });
|
||||
}
|
||||
|
||||
// Set the user in the request
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: 'Error verifying API token' });
|
||||
}
|
||||
};
|
||||
|
||||
return { verifyApiToken };
|
||||
try {
|
||||
const { name, expiresAt, permissions } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ message: "Token name is required" });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
sub: req.user.id,
|
||||
permissions
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresAt: expiresAt ? new Date(expiresAt) : undefined }
|
||||
);
|
||||
|
||||
const apiToken = storage.createApiToken({
|
||||
name,
|
||||
token,
|
||||
userId: req.user.id,
|
||||
permissions: permissions || {},
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
});
|
||||
|
||||
res.status(201).json(apiToken);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to check API token authentication
|
||||
const authenticateApiToken = passport.authenticate("jwt", { session: false });
|
||||
|
||||
return { authenticateApiToken };
|
||||
}
|
||||
|
||||
+816
-5
@@ -1,13 +1,824 @@
|
||||
import type { Express } from "express";
|
||||
import type { Express, Request, Response, NextFunction } from "express";
|
||||
import { createServer, type Server } from "http";
|
||||
import { setupAuth } from "./auth";
|
||||
import { setupSwagger } from "./swagger";
|
||||
import { storage } from "./storage";
|
||||
import { apiQuerySchema } from "@shared/schema";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export async function registerRoutes(app: Express): Promise<Server> {
|
||||
// put application routes here
|
||||
// prefix all routes with /api
|
||||
// Setup authentication
|
||||
const { authenticateApiToken } = setupAuth(app);
|
||||
|
||||
// use storage to perform CRUD operations on the storage interface
|
||||
// e.g. storage.insertUser(user) or storage.getUserByUsername(username)
|
||||
// Setup Swagger documentation
|
||||
setupSwagger(app);
|
||||
|
||||
// Error handler for Zod validation errors
|
||||
const handleZodError = (err: ZodError, res: Response) => {
|
||||
return res.status(400).json({
|
||||
message: "Validation error",
|
||||
errors: err.errors,
|
||||
});
|
||||
};
|
||||
|
||||
// Parse query parameters
|
||||
const parseQueryParams = (req: Request) => {
|
||||
try {
|
||||
return apiQuerySchema.parse(req.query);
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to check admin role
|
||||
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.isAuthenticated() || req.user.role !== "admin") {
|
||||
return res.status(403).json({ message: "Access denied: Admin role required" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ldap-connections:
|
||||
* get:
|
||||
* summary: List all LDAP connections
|
||||
* tags: [LDAP Connections]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of LDAP connections
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/LdapConnection'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
*/
|
||||
app.get("/api/ldap-connections", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const connections = await storage.listLdapConnections();
|
||||
|
||||
// Hide sensitive fields like password
|
||||
const safeConnections = connections.map(conn => {
|
||||
const { password, ...safeConn } = conn;
|
||||
return safeConn;
|
||||
});
|
||||
|
||||
res.json(safeConnections);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ldap-connections:
|
||||
* post:
|
||||
* summary: Create a new LDAP connection
|
||||
* tags: [LDAP Connections]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - server
|
||||
* - domain
|
||||
* - username
|
||||
* - password
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* server:
|
||||
* type: string
|
||||
* domain:
|
||||
* type: string
|
||||
* port:
|
||||
* type: integer
|
||||
* default: 389
|
||||
* useSSL:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Connection created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LdapConnection'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequestError'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
*/
|
||||
app.post("/api/ldap-connections", requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const connection = await storage.createLdapConnection(req.body);
|
||||
|
||||
// Hide password in response
|
||||
const { password, ...safeConn } = connection;
|
||||
|
||||
res.status(201).json(safeConn);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ldap-connections/{id}:
|
||||
* get:
|
||||
* summary: Get a specific LDAP connection
|
||||
* tags: [LDAP Connections]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: LDAP connection details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LdapConnection'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 404:
|
||||
* $ref: '#/components/responses/NotFoundError'
|
||||
*/
|
||||
app.get("/api/ldap-connections/:id", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const connection = await storage.getLdapConnection(parseInt(req.params.id));
|
||||
|
||||
if (!connection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
// Hide password in response
|
||||
const { password, ...safeConn } = connection;
|
||||
|
||||
res.json(safeConn);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ldap-connections/{id}:
|
||||
* put:
|
||||
* summary: Update a LDAP connection
|
||||
* tags: [LDAP Connections]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* server:
|
||||
* type: string
|
||||
* domain:
|
||||
* type: string
|
||||
* port:
|
||||
* type: integer
|
||||
* useSSL:
|
||||
* type: boolean
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LdapConnection'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequestError'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 404:
|
||||
* $ref: '#/components/responses/NotFoundError'
|
||||
*/
|
||||
app.put("/api/ldap-connections/:id", requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const updatedConnection = await storage.updateLdapConnection(parseInt(req.params.id), req.body);
|
||||
|
||||
if (!updatedConnection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
// Hide password in response
|
||||
const { password, ...safeConn } = updatedConnection;
|
||||
|
||||
res.json(safeConn);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/ldap-connections/{id}:
|
||||
* delete:
|
||||
* summary: Delete a LDAP connection
|
||||
* tags: [LDAP Connections]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection deleted successfully
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 404:
|
||||
* $ref: '#/components/responses/NotFoundError'
|
||||
*/
|
||||
app.delete("/api/ldap-connections/:id", requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const deleted = await storage.deleteLdapConnection(parseInt(req.params.id));
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/tokens:
|
||||
* get:
|
||||
* summary: List all API tokens for current user
|
||||
* tags: [API Tokens]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of API tokens
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/ApiToken'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
*/
|
||||
app.get("/api/tokens", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const tokens = await storage.listApiTokensByUserId(req.user.id);
|
||||
res.json(tokens);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/tokens/{id}:
|
||||
* delete:
|
||||
* summary: Delete an API token
|
||||
* tags: [API Tokens]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Token deleted successfully
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 404:
|
||||
* $ref: '#/components/responses/NotFoundError'
|
||||
*/
|
||||
app.delete("/api/tokens/:id", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ message: "Not authenticated" });
|
||||
}
|
||||
|
||||
const token = await storage.getApiToken(parseInt(req.params.id));
|
||||
|
||||
if (!token) {
|
||||
return res.status(404).json({ message: "Token not found" });
|
||||
}
|
||||
|
||||
// Only allow users to delete their own tokens unless they're admin
|
||||
if (token.userId !== req.user.id && req.user.role !== "admin") {
|
||||
return res.status(403).json({ message: "Forbidden: You cannot delete tokens that don't belong to you" });
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteApiToken(parseInt(req.params.id));
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ message: "Token not found" });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/users:
|
||||
* get:
|
||||
* summary: List all users (admin only)
|
||||
* tags: [Users]
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of users
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/User'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 403:
|
||||
* description: Forbidden - admin access required
|
||||
*/
|
||||
app.get("/api/users", requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const users = await storage.listUsers();
|
||||
|
||||
// Remove passwords from response
|
||||
const safeUsers = users.map(user => {
|
||||
const { password, ...safeUser } = user;
|
||||
return safeUser;
|
||||
});
|
||||
|
||||
res.json(safeUsers);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/connections/{connectionId}/ad-users:
|
||||
* get:
|
||||
* summary: List AD users from the specified LDAP connection
|
||||
* tags: [AD Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: connectionId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - $ref: '#/components/parameters/filterParam'
|
||||
* - $ref: '#/components/parameters/selectParam'
|
||||
* - $ref: '#/components/parameters/expandParam'
|
||||
* - $ref: '#/components/parameters/orderByParam'
|
||||
* - $ref: '#/components/parameters/topParam'
|
||||
* - $ref: '#/components/parameters/skipParam'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of AD users
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/AdUser'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
*/
|
||||
app.get("/api/connections/:connectionId/ad-users", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated() && !req.headers.authorization) {
|
||||
return res.status(401).json({ message: "Authentication required" });
|
||||
}
|
||||
|
||||
const connectionId = parseInt(req.params.connectionId);
|
||||
const connection = await storage.getLdapConnection(connectionId);
|
||||
|
||||
if (!connection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
const query = parseQueryParams(req);
|
||||
const users = await storage.listAdUsers(connectionId, query);
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/connections/{connectionId}/ad-users:
|
||||
* post:
|
||||
* summary: Create a new AD user
|
||||
* tags: [AD Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: connectionId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - distinguishedName
|
||||
* - sAMAccountName
|
||||
* properties:
|
||||
* distinguishedName:
|
||||
* type: string
|
||||
* sAMAccountName:
|
||||
* type: string
|
||||
* userPrincipalName:
|
||||
* type: string
|
||||
* givenName:
|
||||
* type: string
|
||||
* surname:
|
||||
* type: string
|
||||
* displayName:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* adProperties:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: User created successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AdUser'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequestError'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
*/
|
||||
app.post("/api/connections/:connectionId/ad-users", authenticateApiToken, async (req, res, next) => {
|
||||
try {
|
||||
const connectionId = parseInt(req.params.connectionId);
|
||||
const connection = await storage.getLdapConnection(connectionId);
|
||||
|
||||
if (!connection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
const userData = { ...req.body, connectionId };
|
||||
const user = await storage.createAdUser(userData);
|
||||
|
||||
res.status(201).json(user);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/connections/{connectionId}/ad-users/{id}:
|
||||
* get:
|
||||
* summary: Get a specific AD user
|
||||
* tags: [AD Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: connectionId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - $ref: '#/components/parameters/selectParam'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AD user details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AdUser'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 404:
|
||||
* $ref: '#/components/responses/NotFoundError'
|
||||
*/
|
||||
app.get("/api/connections/:connectionId/ad-users/:id", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated() && !req.headers.authorization) {
|
||||
return res.status(401).json({ message: "Authentication required" });
|
||||
}
|
||||
|
||||
const user = await storage.getAdUser(parseInt(req.params.id));
|
||||
|
||||
if (!user || user.connectionId !== parseInt(req.params.connectionId)) {
|
||||
return res.status(404).json({ message: "AD user not found" });
|
||||
}
|
||||
|
||||
// Apply property selection if specified
|
||||
let result = user;
|
||||
if (req.query.select) {
|
||||
const properties = (req.query.select as string).split(',');
|
||||
const selectedUser: any = { id: user.id };
|
||||
properties.forEach(prop => {
|
||||
if ((user as any)[prop] !== undefined) {
|
||||
selectedUser[prop] = (user as any)[prop];
|
||||
}
|
||||
});
|
||||
result = selectedUser as typeof user;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/connections/{connectionId}/ad-users/{id}:
|
||||
* put:
|
||||
* summary: Update an AD user
|
||||
* tags: [AD Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: connectionId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* distinguishedName:
|
||||
* type: string
|
||||
* sAMAccountName:
|
||||
* type: string
|
||||
* userPrincipalName:
|
||||
* type: string
|
||||
* givenName:
|
||||
* type: string
|
||||
* surname:
|
||||
* type: string
|
||||
* displayName:
|
||||
* type: string
|
||||
* email:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* adProperties:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User updated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/AdUser'
|
||||
* 400:
|
||||
* $ref: '#/components/responses/BadRequestError'
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 404:
|
||||
* $ref: '#/components/responses/NotFoundError'
|
||||
*/
|
||||
app.put("/api/connections/:connectionId/ad-users/:id", authenticateApiToken, async (req, res, next) => {
|
||||
try {
|
||||
const user = await storage.getAdUser(parseInt(req.params.id));
|
||||
|
||||
if (!user || user.connectionId !== parseInt(req.params.connectionId)) {
|
||||
return res.status(404).json({ message: "AD user not found" });
|
||||
}
|
||||
|
||||
const updatedUser = await storage.updateAdUser(parseInt(req.params.id), req.body);
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/connections/{connectionId}/ad-users/{id}:
|
||||
* delete:
|
||||
* summary: Delete an AD user
|
||||
* tags: [AD Users]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: connectionId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User deleted successfully
|
||||
* 401:
|
||||
* $ref: '#/components/responses/UnauthorizedError'
|
||||
* 404:
|
||||
* $ref: '#/components/responses/NotFoundError'
|
||||
*/
|
||||
app.delete("/api/connections/:connectionId/ad-users/:id", authenticateApiToken, async (req, res, next) => {
|
||||
try {
|
||||
const user = await storage.getAdUser(parseInt(req.params.id));
|
||||
|
||||
if (!user || user.connectionId !== parseInt(req.params.connectionId)) {
|
||||
return res.status(404).json({ message: "AD user not found" });
|
||||
}
|
||||
|
||||
const deleted = await storage.deleteAdUser(parseInt(req.params.id));
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ message: "AD user not found" });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Similar endpoints for AD Groups
|
||||
app.get("/api/connections/:connectionId/ad-groups", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated() && !req.headers.authorization) {
|
||||
return res.status(401).json({ message: "Authentication required" });
|
||||
}
|
||||
|
||||
const connectionId = parseInt(req.params.connectionId);
|
||||
const connection = await storage.getLdapConnection(connectionId);
|
||||
|
||||
if (!connection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
const query = parseQueryParams(req);
|
||||
const groups = await storage.listAdGroups(connectionId, query);
|
||||
|
||||
res.json(groups);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Organizational Units endpoints
|
||||
app.get("/api/connections/:connectionId/ad-org-units", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated() && !req.headers.authorization) {
|
||||
return res.status(401).json({ message: "Authentication required" });
|
||||
}
|
||||
|
||||
const connectionId = parseInt(req.params.connectionId);
|
||||
const connection = await storage.getLdapConnection(connectionId);
|
||||
|
||||
if (!connection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
const query = parseQueryParams(req);
|
||||
const orgUnits = await storage.listAdOrgUnits(connectionId, query);
|
||||
|
||||
res.json(orgUnits);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Computers endpoints
|
||||
app.get("/api/connections/:connectionId/ad-computers", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated() && !req.headers.authorization) {
|
||||
return res.status(401).json({ message: "Authentication required" });
|
||||
}
|
||||
|
||||
const connectionId = parseInt(req.params.connectionId);
|
||||
const connection = await storage.getLdapConnection(connectionId);
|
||||
|
||||
if (!connection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
const query = parseQueryParams(req);
|
||||
const computers = await storage.listAdComputers(connectionId, query);
|
||||
|
||||
res.json(computers);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Domains endpoints
|
||||
app.get("/api/connections/:connectionId/ad-domains", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isAuthenticated() && !req.headers.authorization) {
|
||||
return res.status(401).json({ message: "Authentication required" });
|
||||
}
|
||||
|
||||
const connectionId = parseInt(req.params.connectionId);
|
||||
const connection = await storage.getLdapConnection(connectionId);
|
||||
|
||||
if (!connection) {
|
||||
return res.status(404).json({ message: "LDAP connection not found" });
|
||||
}
|
||||
|
||||
const query = parseQueryParams(req);
|
||||
const domains = await storage.listAdDomains(connectionId, query);
|
||||
|
||||
res.json(domains);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
|
||||
|
||||
+371
-99
@@ -1,44 +1,76 @@
|
||||
import { users, apiTokens, ldapConnections, activityLogs } from "@shared/schema";
|
||||
import type {
|
||||
User, InsertUser,
|
||||
ApiToken, InsertApiToken,
|
||||
import {
|
||||
User, InsertUser, ApiToken, InsertApiToken,
|
||||
LdapConnection, InsertLdapConnection,
|
||||
ActivityLog, InsertActivityLog
|
||||
AdUser, InsertAdUser, AdGroup, InsertAdGroup,
|
||||
AdOrgUnit, InsertAdOrgUnit, AdComputer, InsertAdComputer,
|
||||
AdDomain, InsertAdDomain
|
||||
} from "@shared/schema";
|
||||
import session from "express-session";
|
||||
import createMemoryStore from "memorystore";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Memory store for sessions
|
||||
const MemoryStore = createMemoryStore(session);
|
||||
|
||||
export interface IStorage {
|
||||
// Users
|
||||
// User management
|
||||
getUser(id: number): Promise<User | undefined>;
|
||||
getUserByUsername(username: string): Promise<User | undefined>;
|
||||
createUser(user: InsertUser): Promise<User>;
|
||||
updateUser(id: number, user: Partial<User>): Promise<User | undefined>;
|
||||
deleteUser(id: number): Promise<boolean>;
|
||||
listUsers(filters?: any): Promise<User[]>;
|
||||
listUsers(): Promise<User[]>;
|
||||
|
||||
// API Tokens
|
||||
// API Token management
|
||||
getApiToken(id: number): Promise<ApiToken | undefined>;
|
||||
getApiTokenByToken(token: string): Promise<ApiToken | undefined>;
|
||||
createApiToken(token: InsertApiToken & { token: string }): Promise<ApiToken>;
|
||||
updateApiToken(id: number, token: Partial<ApiToken>): Promise<ApiToken | undefined>;
|
||||
createApiToken(token: InsertApiToken): Promise<ApiToken>;
|
||||
deleteApiToken(id: number): Promise<boolean>;
|
||||
listApiTokens(userId?: number): Promise<ApiToken[]>;
|
||||
listApiTokensByUserId(userId: number): Promise<ApiToken[]>;
|
||||
|
||||
// LDAP Connections
|
||||
// LDAP Connection management
|
||||
getLdapConnection(id: number): Promise<LdapConnection | undefined>;
|
||||
createLdapConnection(connection: InsertLdapConnection): Promise<LdapConnection>;
|
||||
updateLdapConnection(id: number, connection: Partial<LdapConnection>): Promise<LdapConnection | undefined>;
|
||||
deleteLdapConnection(id: number): Promise<boolean>;
|
||||
listLdapConnections(): Promise<LdapConnection[]>;
|
||||
|
||||
// Activity Logs
|
||||
createActivityLog(log: InsertActivityLog): Promise<ActivityLog>;
|
||||
listActivityLogs(limit?: number): Promise<ActivityLog[]>;
|
||||
// AD Users
|
||||
getAdUser(id: number): Promise<AdUser | undefined>;
|
||||
createAdUser(user: InsertAdUser): Promise<AdUser>;
|
||||
updateAdUser(id: number, user: Partial<AdUser>): Promise<AdUser | undefined>;
|
||||
deleteAdUser(id: number): Promise<boolean>;
|
||||
listAdUsers(connectionId: number, query?: any): Promise<AdUser[]>;
|
||||
|
||||
// Session Store
|
||||
// AD Groups
|
||||
getAdGroup(id: number): Promise<AdGroup | undefined>;
|
||||
createAdGroup(group: InsertAdGroup): Promise<AdGroup>;
|
||||
updateAdGroup(id: number, group: Partial<AdGroup>): Promise<AdGroup | undefined>;
|
||||
deleteAdGroup(id: number): Promise<boolean>;
|
||||
listAdGroups(connectionId: number, query?: any): Promise<AdGroup[]>;
|
||||
|
||||
// AD Organizational Units
|
||||
getAdOrgUnit(id: number): Promise<AdOrgUnit | undefined>;
|
||||
createAdOrgUnit(ou: InsertAdOrgUnit): Promise<AdOrgUnit>;
|
||||
updateAdOrgUnit(id: number, ou: Partial<AdOrgUnit>): Promise<AdOrgUnit | undefined>;
|
||||
deleteAdOrgUnit(id: number): Promise<boolean>;
|
||||
listAdOrgUnits(connectionId: number, query?: any): Promise<AdOrgUnit[]>;
|
||||
|
||||
// AD Computers
|
||||
getAdComputer(id: number): Promise<AdComputer | undefined>;
|
||||
createAdComputer(computer: InsertAdComputer): Promise<AdComputer>;
|
||||
updateAdComputer(id: number, computer: Partial<AdComputer>): Promise<AdComputer | undefined>;
|
||||
deleteAdComputer(id: number): Promise<boolean>;
|
||||
listAdComputers(connectionId: number, query?: any): Promise<AdComputer[]>;
|
||||
|
||||
// AD Domains
|
||||
getAdDomain(id: number): Promise<AdDomain | undefined>;
|
||||
createAdDomain(domain: InsertAdDomain): Promise<AdDomain>;
|
||||
updateAdDomain(id: number, domain: Partial<AdDomain>): Promise<AdDomain | undefined>;
|
||||
deleteAdDomain(id: number): Promise<boolean>;
|
||||
listAdDomains(connectionId: number, query?: any): Promise<AdDomain[]>;
|
||||
|
||||
// Session store
|
||||
sessionStore: session.SessionStore;
|
||||
}
|
||||
|
||||
@@ -46,31 +78,47 @@ export class MemStorage implements IStorage {
|
||||
private users: Map<number, User>;
|
||||
private apiTokens: Map<number, ApiToken>;
|
||||
private ldapConnections: Map<number, LdapConnection>;
|
||||
private activityLogs: ActivityLog[];
|
||||
|
||||
currentUserId: number;
|
||||
currentTokenId: number;
|
||||
currentConnectionId: number;
|
||||
currentLogId: number;
|
||||
private adUsers: Map<number, AdUser>;
|
||||
private adGroups: Map<number, AdGroup>;
|
||||
private adOrgUnits: Map<number, AdOrgUnit>;
|
||||
private adComputers: Map<number, AdComputer>;
|
||||
private adDomains: Map<number, AdDomain>;
|
||||
sessionStore: session.SessionStore;
|
||||
|
||||
private userCurrentId: number;
|
||||
private tokenCurrentId: number;
|
||||
private connectionCurrentId: number;
|
||||
private adUserCurrentId: number;
|
||||
private adGroupCurrentId: number;
|
||||
private adOrgUnitCurrentId: number;
|
||||
private adComputerCurrentId: number;
|
||||
private adDomainCurrentId: number;
|
||||
|
||||
constructor() {
|
||||
this.users = new Map();
|
||||
this.apiTokens = new Map();
|
||||
this.ldapConnections = new Map();
|
||||
this.activityLogs = [];
|
||||
|
||||
this.currentUserId = 1;
|
||||
this.currentTokenId = 1;
|
||||
this.currentConnectionId = 1;
|
||||
this.currentLogId = 1;
|
||||
this.adUsers = new Map();
|
||||
this.adGroups = new Map();
|
||||
this.adOrgUnits = new Map();
|
||||
this.adComputers = new Map();
|
||||
this.adDomains = new Map();
|
||||
|
||||
this.userCurrentId = 1;
|
||||
this.tokenCurrentId = 1;
|
||||
this.connectionCurrentId = 1;
|
||||
this.adUserCurrentId = 1;
|
||||
this.adGroupCurrentId = 1;
|
||||
this.adOrgUnitCurrentId = 1;
|
||||
this.adComputerCurrentId = 1;
|
||||
this.adDomainCurrentId = 1;
|
||||
|
||||
this.sessionStore = new MemoryStore({
|
||||
checkPeriod: 86400000, // 24 hours
|
||||
checkPeriod: 86400000 // 24h
|
||||
});
|
||||
}
|
||||
|
||||
// Users
|
||||
// User management
|
||||
async getUser(id: number): Promise<User | undefined> {
|
||||
return this.users.get(id);
|
||||
}
|
||||
@@ -82,18 +130,23 @@ export class MemStorage implements IStorage {
|
||||
}
|
||||
|
||||
async createUser(insertUser: InsertUser): Promise<User> {
|
||||
const id = this.currentUserId++;
|
||||
const createdAt = new Date();
|
||||
const user: User = { ...insertUser, id, createdAt };
|
||||
const id = this.userCurrentId++;
|
||||
const now = new Date();
|
||||
const user: User = {
|
||||
...insertUser,
|
||||
id,
|
||||
createdAt: now,
|
||||
role: insertUser.role || "user"
|
||||
};
|
||||
this.users.set(id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUser(id: number, updates: Partial<User>): Promise<User | undefined> {
|
||||
const user = this.users.get(id);
|
||||
async updateUser(id: number, userData: Partial<User>): Promise<User | undefined> {
|
||||
const user = await this.getUser(id);
|
||||
if (!user) return undefined;
|
||||
|
||||
const updatedUser = { ...user, ...updates };
|
||||
const updatedUser = { ...user, ...userData };
|
||||
this.users.set(id, updatedUser);
|
||||
return updatedUser;
|
||||
}
|
||||
@@ -102,22 +155,11 @@ export class MemStorage implements IStorage {
|
||||
return this.users.delete(id);
|
||||
}
|
||||
|
||||
async listUsers(filters?: any): Promise<User[]> {
|
||||
const users = Array.from(this.users.values());
|
||||
if (!filters) return users;
|
||||
|
||||
// Apply filters if provided
|
||||
return users.filter(user => {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (user[key as keyof User] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
async listUsers(): Promise<User[]> {
|
||||
return Array.from(this.users.values());
|
||||
}
|
||||
|
||||
// API Tokens
|
||||
// API Token management
|
||||
async getApiToken(id: number): Promise<ApiToken | undefined> {
|
||||
return this.apiTokens.get(id);
|
||||
}
|
||||
@@ -128,61 +170,48 @@ export class MemStorage implements IStorage {
|
||||
);
|
||||
}
|
||||
|
||||
async createApiToken(tokenData: InsertApiToken & { token: string }): Promise<ApiToken> {
|
||||
const id = this.currentTokenId++;
|
||||
const createdAt = new Date();
|
||||
const apiToken: ApiToken = {
|
||||
...tokenData,
|
||||
id,
|
||||
createdAt,
|
||||
lastUsedAt: null
|
||||
};
|
||||
this.apiTokens.set(id, apiToken);
|
||||
return apiToken;
|
||||
}
|
||||
|
||||
async updateApiToken(id: number, updates: Partial<ApiToken>): Promise<ApiToken | undefined> {
|
||||
const token = this.apiTokens.get(id);
|
||||
if (!token) return undefined;
|
||||
|
||||
const updatedToken = { ...token, ...updates };
|
||||
this.apiTokens.set(id, updatedToken);
|
||||
return updatedToken;
|
||||
async createApiToken(insertToken: InsertApiToken): Promise<ApiToken> {
|
||||
const id = this.tokenCurrentId++;
|
||||
const now = new Date();
|
||||
const token: ApiToken = { ...insertToken, id, createdAt: now };
|
||||
this.apiTokens.set(id, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
async deleteApiToken(id: number): Promise<boolean> {
|
||||
return this.apiTokens.delete(id);
|
||||
}
|
||||
|
||||
async listApiTokens(userId?: number): Promise<ApiToken[]> {
|
||||
const tokens = Array.from(this.apiTokens.values());
|
||||
if (userId === undefined) return tokens;
|
||||
|
||||
return tokens.filter(token => token.userId === userId);
|
||||
async listApiTokensByUserId(userId: number): Promise<ApiToken[]> {
|
||||
return Array.from(this.apiTokens.values()).filter(
|
||||
(token) => token.userId === userId,
|
||||
);
|
||||
}
|
||||
|
||||
// LDAP Connections
|
||||
// LDAP Connection management
|
||||
async getLdapConnection(id: number): Promise<LdapConnection | undefined> {
|
||||
return this.ldapConnections.get(id);
|
||||
}
|
||||
|
||||
async createLdapConnection(connectionData: InsertLdapConnection): Promise<LdapConnection> {
|
||||
const id = this.currentConnectionId++;
|
||||
const ldapConnection: LdapConnection = {
|
||||
...connectionData,
|
||||
async createLdapConnection(insertConnection: InsertLdapConnection): Promise<LdapConnection> {
|
||||
const id = this.connectionCurrentId++;
|
||||
const now = new Date();
|
||||
const connection: LdapConnection = {
|
||||
...insertConnection,
|
||||
id,
|
||||
status: "disconnected",
|
||||
lastConnected: null
|
||||
createdAt: now,
|
||||
lastConnected: null,
|
||||
status: "disconnected"
|
||||
};
|
||||
this.ldapConnections.set(id, ldapConnection);
|
||||
return ldapConnection;
|
||||
this.ldapConnections.set(id, connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
async updateLdapConnection(id: number, updates: Partial<LdapConnection>): Promise<LdapConnection | undefined> {
|
||||
const connection = this.ldapConnections.get(id);
|
||||
async updateLdapConnection(id: number, connectionData: Partial<LdapConnection>): Promise<LdapConnection | undefined> {
|
||||
const connection = await this.getLdapConnection(id);
|
||||
if (!connection) return undefined;
|
||||
|
||||
const updatedConnection = { ...connection, ...updates };
|
||||
const updatedConnection = { ...connection, ...connectionData };
|
||||
this.ldapConnections.set(id, updatedConnection);
|
||||
return updatedConnection;
|
||||
}
|
||||
@@ -195,20 +224,263 @@ export class MemStorage implements IStorage {
|
||||
return Array.from(this.ldapConnections.values());
|
||||
}
|
||||
|
||||
// Activity Logs
|
||||
async createActivityLog(logData: InsertActivityLog): Promise<ActivityLog> {
|
||||
const id = this.currentLogId++;
|
||||
const timestamp = new Date();
|
||||
const log: ActivityLog = { ...logData, id, timestamp };
|
||||
this.activityLogs.push(log);
|
||||
return log;
|
||||
// AD Users
|
||||
async getAdUser(id: number): Promise<AdUser | undefined> {
|
||||
return this.adUsers.get(id);
|
||||
}
|
||||
|
||||
async listActivityLogs(limit = 10): Promise<ActivityLog[]> {
|
||||
// Sort by timestamp descending (newest first)
|
||||
return [...this.activityLogs]
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, limit);
|
||||
async createAdUser(user: InsertAdUser): Promise<AdUser> {
|
||||
const id = this.adUserCurrentId++;
|
||||
const adUser: AdUser = { ...user, id };
|
||||
this.adUsers.set(id, adUser);
|
||||
return adUser;
|
||||
}
|
||||
|
||||
async updateAdUser(id: number, userData: Partial<AdUser>): Promise<AdUser | undefined> {
|
||||
const user = await this.getAdUser(id);
|
||||
if (!user) return undefined;
|
||||
|
||||
const updatedUser = { ...user, ...userData };
|
||||
this.adUsers.set(id, updatedUser);
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async deleteAdUser(id: number): Promise<boolean> {
|
||||
return this.adUsers.delete(id);
|
||||
}
|
||||
|
||||
async listAdUsers(connectionId: number, query?: any): Promise<AdUser[]> {
|
||||
let users = Array.from(this.adUsers.values()).filter(
|
||||
(user) => user.connectionId === connectionId,
|
||||
);
|
||||
|
||||
// Apply filtering logic based on query
|
||||
if (query) {
|
||||
if (query.filter) {
|
||||
// Simple filter implementation - can be expanded
|
||||
const filterParts = query.filter.split(' ');
|
||||
if (filterParts.length === 3) {
|
||||
const [property, operator, value] = filterParts;
|
||||
const unquotedValue = value.replace(/^'|'$/g, '');
|
||||
|
||||
if (operator === 'eq') {
|
||||
users = users.filter(user => (user as any)[property] === unquotedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply property selection
|
||||
if (query.select) {
|
||||
const properties = query.select.split(',');
|
||||
users = users.map(user => {
|
||||
const result: any = { id: user.id };
|
||||
properties.forEach(prop => {
|
||||
if ((user as any)[prop] !== undefined) {
|
||||
result[prop] = (user as any)[prop];
|
||||
}
|
||||
});
|
||||
return result as AdUser;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
// AD Groups
|
||||
async getAdGroup(id: number): Promise<AdGroup | undefined> {
|
||||
return this.adGroups.get(id);
|
||||
}
|
||||
|
||||
async createAdGroup(group: InsertAdGroup): Promise<AdGroup> {
|
||||
const id = this.adGroupCurrentId++;
|
||||
const adGroup: AdGroup = { ...group, id };
|
||||
this.adGroups.set(id, adGroup);
|
||||
return adGroup;
|
||||
}
|
||||
|
||||
async updateAdGroup(id: number, groupData: Partial<AdGroup>): Promise<AdGroup | undefined> {
|
||||
const group = await this.getAdGroup(id);
|
||||
if (!group) return undefined;
|
||||
|
||||
const updatedGroup = { ...group, ...groupData };
|
||||
this.adGroups.set(id, updatedGroup);
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
async deleteAdGroup(id: number): Promise<boolean> {
|
||||
return this.adGroups.delete(id);
|
||||
}
|
||||
|
||||
async listAdGroups(connectionId: number, query?: any): Promise<AdGroup[]> {
|
||||
let groups = Array.from(this.adGroups.values()).filter(
|
||||
(group) => group.connectionId === connectionId,
|
||||
);
|
||||
|
||||
// Apply filtering logic
|
||||
if (query) {
|
||||
if (query.select) {
|
||||
const properties = query.select.split(',');
|
||||
groups = groups.map(group => {
|
||||
const result: any = { id: group.id };
|
||||
properties.forEach(prop => {
|
||||
if ((group as any)[prop] !== undefined) {
|
||||
result[prop] = (group as any)[prop];
|
||||
}
|
||||
});
|
||||
return result as AdGroup;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// AD Organizational Units
|
||||
async getAdOrgUnit(id: number): Promise<AdOrgUnit | undefined> {
|
||||
return this.adOrgUnits.get(id);
|
||||
}
|
||||
|
||||
async createAdOrgUnit(ou: InsertAdOrgUnit): Promise<AdOrgUnit> {
|
||||
const id = this.adOrgUnitCurrentId++;
|
||||
const adOrgUnit: AdOrgUnit = { ...ou, id };
|
||||
this.adOrgUnits.set(id, adOrgUnit);
|
||||
return adOrgUnit;
|
||||
}
|
||||
|
||||
async updateAdOrgUnit(id: number, ouData: Partial<AdOrgUnit>): Promise<AdOrgUnit | undefined> {
|
||||
const ou = await this.getAdOrgUnit(id);
|
||||
if (!ou) return undefined;
|
||||
|
||||
const updatedOu = { ...ou, ...ouData };
|
||||
this.adOrgUnits.set(id, updatedOu);
|
||||
return updatedOu;
|
||||
}
|
||||
|
||||
async deleteAdOrgUnit(id: number): Promise<boolean> {
|
||||
return this.adOrgUnits.delete(id);
|
||||
}
|
||||
|
||||
async listAdOrgUnits(connectionId: number, query?: any): Promise<AdOrgUnit[]> {
|
||||
let orgUnits = Array.from(this.adOrgUnits.values()).filter(
|
||||
(ou) => ou.connectionId === connectionId,
|
||||
);
|
||||
|
||||
// Apply filtering logic
|
||||
if (query) {
|
||||
if (query.select) {
|
||||
const properties = query.select.split(',');
|
||||
orgUnits = orgUnits.map(ou => {
|
||||
const result: any = { id: ou.id };
|
||||
properties.forEach(prop => {
|
||||
if ((ou as any)[prop] !== undefined) {
|
||||
result[prop] = (ou as any)[prop];
|
||||
}
|
||||
});
|
||||
return result as AdOrgUnit;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return orgUnits;
|
||||
}
|
||||
|
||||
// AD Computers
|
||||
async getAdComputer(id: number): Promise<AdComputer | undefined> {
|
||||
return this.adComputers.get(id);
|
||||
}
|
||||
|
||||
async createAdComputer(computer: InsertAdComputer): Promise<AdComputer> {
|
||||
const id = this.adComputerCurrentId++;
|
||||
const adComputer: AdComputer = { ...computer, id };
|
||||
this.adComputers.set(id, adComputer);
|
||||
return adComputer;
|
||||
}
|
||||
|
||||
async updateAdComputer(id: number, computerData: Partial<AdComputer>): Promise<AdComputer | undefined> {
|
||||
const computer = await this.getAdComputer(id);
|
||||
if (!computer) return undefined;
|
||||
|
||||
const updatedComputer = { ...computer, ...computerData };
|
||||
this.adComputers.set(id, updatedComputer);
|
||||
return updatedComputer;
|
||||
}
|
||||
|
||||
async deleteAdComputer(id: number): Promise<boolean> {
|
||||
return this.adComputers.delete(id);
|
||||
}
|
||||
|
||||
async listAdComputers(connectionId: number, query?: any): Promise<AdComputer[]> {
|
||||
let computers = Array.from(this.adComputers.values()).filter(
|
||||
(computer) => computer.connectionId === connectionId,
|
||||
);
|
||||
|
||||
// Apply filtering logic
|
||||
if (query) {
|
||||
if (query.select) {
|
||||
const properties = query.select.split(',');
|
||||
computers = computers.map(computer => {
|
||||
const result: any = { id: computer.id };
|
||||
properties.forEach(prop => {
|
||||
if ((computer as any)[prop] !== undefined) {
|
||||
result[prop] = (computer as any)[prop];
|
||||
}
|
||||
});
|
||||
return result as AdComputer;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return computers;
|
||||
}
|
||||
|
||||
// AD Domains
|
||||
async getAdDomain(id: number): Promise<AdDomain | undefined> {
|
||||
return this.adDomains.get(id);
|
||||
}
|
||||
|
||||
async createAdDomain(domain: InsertAdDomain): Promise<AdDomain> {
|
||||
const id = this.adDomainCurrentId++;
|
||||
const adDomain: AdDomain = { ...domain, id };
|
||||
this.adDomains.set(id, adDomain);
|
||||
return adDomain;
|
||||
}
|
||||
|
||||
async updateAdDomain(id: number, domainData: Partial<AdDomain>): Promise<AdDomain | undefined> {
|
||||
const domain = await this.getAdDomain(id);
|
||||
if (!domain) return undefined;
|
||||
|
||||
const updatedDomain = { ...domain, ...domainData };
|
||||
this.adDomains.set(id, updatedDomain);
|
||||
return updatedDomain;
|
||||
}
|
||||
|
||||
async deleteAdDomain(id: number): Promise<boolean> {
|
||||
return this.adDomains.delete(id);
|
||||
}
|
||||
|
||||
async listAdDomains(connectionId: number, query?: any): Promise<AdDomain[]> {
|
||||
let domains = Array.from(this.adDomains.values()).filter(
|
||||
(domain) => domain.connectionId === connectionId,
|
||||
);
|
||||
|
||||
// Apply filtering logic
|
||||
if (query) {
|
||||
if (query.select) {
|
||||
const properties = query.select.split(',');
|
||||
domains = domains.map(domain => {
|
||||
const result: any = { id: domain.id };
|
||||
properties.forEach(prop => {
|
||||
if ((domain as any)[prop] !== undefined) {
|
||||
result[prop] = (domain as any)[prop];
|
||||
}
|
||||
});
|
||||
return result as AdDomain;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return domains;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+242
-277
@@ -1,283 +1,248 @@
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import { Express } from 'express';
|
||||
import swaggerJsdoc from "swagger-jsdoc";
|
||||
import swaggerUi from "swagger-ui-express";
|
||||
import { Express } from "express";
|
||||
|
||||
export const setupSwagger = (app: Express) => {
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Active Directory Management API',
|
||||
version: '1.0.0',
|
||||
description: 'RESTful API for managing Active Directory resources',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
email: 'support@example.com'
|
||||
}
|
||||
// Swagger definition
|
||||
const swaggerOptions = {
|
||||
definition: {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Active Directory Management API",
|
||||
version: "1.0.0",
|
||||
description: "REST API for managing Active Directory resources",
|
||||
contact: {
|
||||
name: "API Support",
|
||||
email: "support@example.com",
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api',
|
||||
description: 'API Server'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT'
|
||||
}
|
||||
},
|
||||
schemas: {
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
description: 'User ID'
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
description: 'Username'
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Email address'
|
||||
},
|
||||
isAdmin: {
|
||||
type: 'boolean',
|
||||
description: 'Admin status'
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Creation date'
|
||||
}
|
||||
}
|
||||
},
|
||||
ApiToken: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
description: 'Token ID'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Token name'
|
||||
},
|
||||
token: {
|
||||
type: 'string',
|
||||
description: 'The actual token'
|
||||
},
|
||||
userId: {
|
||||
type: 'integer',
|
||||
description: 'User ID that owns the token'
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Creation date'
|
||||
},
|
||||
lastUsedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Last used date'
|
||||
},
|
||||
expiresAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Expiration date'
|
||||
}
|
||||
}
|
||||
},
|
||||
LdapConnection: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
description: 'Connection ID'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Connection name'
|
||||
},
|
||||
server: {
|
||||
type: 'string',
|
||||
description: 'Server hostname/IP'
|
||||
},
|
||||
port: {
|
||||
type: 'integer',
|
||||
description: 'Server port'
|
||||
},
|
||||
authType: {
|
||||
type: 'string',
|
||||
description: 'Authentication type'
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
description: 'Username'
|
||||
},
|
||||
baseDN: {
|
||||
type: 'string',
|
||||
description: 'Base Distinguished Name'
|
||||
},
|
||||
useTLS: {
|
||||
type: 'boolean',
|
||||
description: 'Use TLS/SSL'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Connection status'
|
||||
},
|
||||
lastConnected: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Last connected timestamp'
|
||||
}
|
||||
}
|
||||
},
|
||||
ActivityLog: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
description: 'Log ID'
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'Action performed'
|
||||
},
|
||||
resource: {
|
||||
type: 'string',
|
||||
description: 'Resource name'
|
||||
},
|
||||
resourceType: {
|
||||
type: 'string',
|
||||
description: 'Resource type'
|
||||
},
|
||||
userId: {
|
||||
type: 'integer',
|
||||
description: 'User ID'
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
description: 'Username'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Status'
|
||||
},
|
||||
details: {
|
||||
type: 'object',
|
||||
description: 'Additional details'
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'Timestamp'
|
||||
}
|
||||
}
|
||||
},
|
||||
LdapUser: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cn: {
|
||||
type: 'string',
|
||||
description: 'Common Name'
|
||||
},
|
||||
sAMAccountName: {
|
||||
type: 'string',
|
||||
description: 'SAM Account Name'
|
||||
},
|
||||
mail: {
|
||||
type: 'string',
|
||||
description: 'Email address'
|
||||
},
|
||||
distinguishedName: {
|
||||
type: 'string',
|
||||
description: 'Distinguished Name'
|
||||
}
|
||||
}
|
||||
},
|
||||
LdapGroup: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cn: {
|
||||
type: 'string',
|
||||
description: 'Common Name'
|
||||
},
|
||||
distinguishedName: {
|
||||
type: 'string',
|
||||
description: 'Distinguished Name'
|
||||
},
|
||||
member: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
description: 'Group members'
|
||||
}
|
||||
}
|
||||
},
|
||||
LdapOU: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ou: {
|
||||
type: 'string',
|
||||
description: 'Organizational Unit name'
|
||||
},
|
||||
distinguishedName: {
|
||||
type: 'string',
|
||||
description: 'Distinguished Name'
|
||||
}
|
||||
}
|
||||
},
|
||||
LdapComputer: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cn: {
|
||||
type: 'string',
|
||||
description: 'Computer name'
|
||||
},
|
||||
distinguishedName: {
|
||||
type: 'string',
|
||||
description: 'Distinguished Name'
|
||||
},
|
||||
operatingSystem: {
|
||||
type: 'string',
|
||||
description: 'Operating System'
|
||||
}
|
||||
}
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Error message'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
]
|
||||
},
|
||||
apis: ['./server/api.ts']
|
||||
};
|
||||
servers: [
|
||||
{
|
||||
url: "/api",
|
||||
description: "API base URL",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
cookieAuth: {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "connect.sid",
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
User: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
username: { type: "string" },
|
||||
email: { type: "string" },
|
||||
fullName: { type: "string" },
|
||||
role: { type: "string" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
ApiToken: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
token: { type: "string" },
|
||||
userId: { type: "integer" },
|
||||
permissions: { type: "object" },
|
||||
expiresAt: { type: "string", format: "date-time" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
LdapConnection: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
server: { type: "string" },
|
||||
domain: { type: "string" },
|
||||
port: { type: "integer" },
|
||||
useSSL: { type: "boolean" },
|
||||
username: { type: "string" },
|
||||
status: { type: "string", enum: ["connected", "disconnected"] },
|
||||
lastConnected: { type: "string", format: "date-time" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
},
|
||||
AdUser: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
connectionId: { type: "integer" },
|
||||
distinguishedName: { type: "string" },
|
||||
sAMAccountName: { type: "string" },
|
||||
userPrincipalName: { type: "string" },
|
||||
givenName: { type: "string" },
|
||||
surname: { type: "string" },
|
||||
displayName: { type: "string" },
|
||||
email: { type: "string" },
|
||||
enabled: { type: "boolean" },
|
||||
lastLogon: { type: "string", format: "date-time" },
|
||||
memberOf: { type: "array", items: { type: "string" } },
|
||||
adProperties: { type: "object" },
|
||||
},
|
||||
},
|
||||
AdGroup: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
connectionId: { type: "integer" },
|
||||
distinguishedName: { type: "string" },
|
||||
sAMAccountName: { type: "string" },
|
||||
groupType: { type: "string" },
|
||||
description: { type: "string" },
|
||||
members: { type: "array", items: { type: "string" } },
|
||||
adProperties: { type: "object" },
|
||||
},
|
||||
},
|
||||
AdOrgUnit: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
connectionId: { type: "integer" },
|
||||
distinguishedName: { type: "string" },
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
adProperties: { type: "object" },
|
||||
},
|
||||
},
|
||||
AdComputer: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
connectionId: { type: "integer" },
|
||||
distinguishedName: { type: "string" },
|
||||
name: { type: "string" },
|
||||
dnsHostName: { type: "string" },
|
||||
operatingSystem: { type: "string" },
|
||||
operatingSystemVersion: { type: "string" },
|
||||
lastLogon: { type: "string", format: "date-time" },
|
||||
enabled: { type: "boolean" },
|
||||
adProperties: { type: "object" },
|
||||
},
|
||||
},
|
||||
AdDomain: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
connectionId: { type: "integer" },
|
||||
distinguishedName: { type: "string" },
|
||||
name: { type: "string" },
|
||||
netBIOSName: { type: "string" },
|
||||
forestName: { type: "string" },
|
||||
domainFunctionality: { type: "string" },
|
||||
adProperties: { type: "object" },
|
||||
},
|
||||
},
|
||||
Error: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
errors: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "array", items: { type: "string" } },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
filterParam: {
|
||||
name: "filter",
|
||||
in: "query",
|
||||
description: "Filter expression (e.g. name eq 'John')",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
selectParam: {
|
||||
name: "select",
|
||||
in: "query",
|
||||
description: "Properties to select (comma-separated)",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
expandParam: {
|
||||
name: "expand",
|
||||
in: "query",
|
||||
description: "Related entities to expand (comma-separated)",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
orderByParam: {
|
||||
name: "orderBy",
|
||||
in: "query",
|
||||
description: "Property to order by (e.g. name asc)",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
topParam: {
|
||||
name: "top",
|
||||
in: "query",
|
||||
description: "Number of records to return",
|
||||
schema: { type: "integer" },
|
||||
},
|
||||
skipParam: {
|
||||
name: "skip",
|
||||
in: "query",
|
||||
description: "Number of records to skip",
|
||||
schema: { type: "integer" },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
UnauthorizedError: {
|
||||
description: "Authentication required",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
BadRequestError: {
|
||||
description: "Invalid request",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
NotFoundError: {
|
||||
description: "Resource not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: [],
|
||||
},
|
||||
{
|
||||
cookieAuth: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: ["./server/routes.ts"], // Path to the API routes
|
||||
};
|
||||
|
||||
const swaggerSpec = swaggerJsdoc(options);
|
||||
|
||||
app.use('/swagger-ui', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
|
||||
// Serve the OpenAPI spec as JSON
|
||||
app.get('/swagger.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
||||
|
||||
export function setupSwagger(app: Express) {
|
||||
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||
app.get("/api/swagger.json", (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.send(swaggerSpec);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
+107
-59
@@ -2,98 +2,146 @@ import { pgTable, text, serial, integer, boolean, timestamp, jsonb } from "drizz
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
// User schema for authentication
|
||||
// User schema for local authentication
|
||||
export const users = pgTable("users", {
|
||||
id: serial("id").primaryKey(),
|
||||
username: text("username").notNull().unique(),
|
||||
password: text("password").notNull(),
|
||||
email: text("email"),
|
||||
isAdmin: boolean("is_admin").default(false).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
fullName: text("full_name"),
|
||||
role: text("role").default("user"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const insertUserSchema = createInsertSchema(users).pick({
|
||||
username: true,
|
||||
password: true,
|
||||
email: true,
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
// API Tokens
|
||||
// API Token schema
|
||||
export const apiTokens = pgTable("api_tokens", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
userId: integer("user_id").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
lastUsedAt: timestamp("last_used_at"),
|
||||
permissions: jsonb("permissions").notNull(),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const insertApiTokenSchema = createInsertSchema(apiTokens).pick({
|
||||
name: true,
|
||||
userId: true,
|
||||
expiresAt: true,
|
||||
});
|
||||
|
||||
// LDAP Connections
|
||||
// LDAP Connection schema
|
||||
export const ldapConnections = pgTable("ldap_connections", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
server: text("server").notNull(),
|
||||
port: integer("port").notNull(),
|
||||
authType: text("auth_type").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
port: integer("port").default(389),
|
||||
useSSL: boolean("use_ssl").default(true),
|
||||
username: text("username").notNull(),
|
||||
password: text("password").notNull(),
|
||||
baseDN: text("base_dn"),
|
||||
useTLS: boolean("use_tls").default(false),
|
||||
status: text("status").default("disconnected"),
|
||||
lastConnected: timestamp("last_connected"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
export const insertLdapConnectionSchema = createInsertSchema(ldapConnections).pick({
|
||||
name: true,
|
||||
server: true,
|
||||
port: true,
|
||||
authType: true,
|
||||
username: true,
|
||||
password: true,
|
||||
baseDN: true,
|
||||
useTLS: true,
|
||||
});
|
||||
|
||||
// Activity Log
|
||||
export const activityLogs = pgTable("activity_logs", {
|
||||
// Active Directory schemas
|
||||
export const adUsers = pgTable("ad_users", {
|
||||
id: serial("id").primaryKey(),
|
||||
action: text("action").notNull(),
|
||||
resource: text("resource").notNull(),
|
||||
resourceType: text("resource_type").notNull(),
|
||||
userId: integer("user_id"),
|
||||
username: text("username"),
|
||||
status: text("status").notNull(),
|
||||
details: jsonb("details"),
|
||||
timestamp: timestamp("timestamp").defaultNow().notNull(),
|
||||
connectionId: integer("connection_id").notNull(),
|
||||
distinguishedName: text("distinguished_name").notNull(),
|
||||
sAMAccountName: text("sam_account_name").notNull(),
|
||||
userPrincipalName: text("user_principal_name"),
|
||||
givenName: text("given_name"),
|
||||
surname: text("surname"),
|
||||
displayName: text("display_name"),
|
||||
email: text("email"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
lastLogon: timestamp("last_logon"),
|
||||
memberOf: jsonb("member_of"),
|
||||
adProperties: jsonb("ad_properties"),
|
||||
});
|
||||
|
||||
export const insertActivityLogSchema = createInsertSchema(activityLogs).pick({
|
||||
action: true,
|
||||
resource: true,
|
||||
resourceType: true,
|
||||
userId: true,
|
||||
username: true,
|
||||
status: true,
|
||||
details: true,
|
||||
export const adGroups = pgTable("ad_groups", {
|
||||
id: serial("id").primaryKey(),
|
||||
connectionId: integer("connection_id").notNull(),
|
||||
distinguishedName: text("distinguished_name").notNull(),
|
||||
sAMAccountName: text("sam_account_name").notNull(),
|
||||
groupType: text("group_type"),
|
||||
description: text("description"),
|
||||
members: jsonb("members"),
|
||||
adProperties: jsonb("ad_properties"),
|
||||
});
|
||||
|
||||
// Types
|
||||
export const adOrgUnits = pgTable("ad_org_units", {
|
||||
id: serial("id").primaryKey(),
|
||||
connectionId: integer("connection_id").notNull(),
|
||||
distinguishedName: text("distinguished_name").notNull(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
adProperties: jsonb("ad_properties"),
|
||||
});
|
||||
|
||||
export const adComputers = pgTable("ad_computers", {
|
||||
id: serial("id").primaryKey(),
|
||||
connectionId: integer("connection_id").notNull(),
|
||||
distinguishedName: text("distinguished_name").notNull(),
|
||||
name: text("name").notNull(),
|
||||
dnsHostName: text("dns_host_name"),
|
||||
operatingSystem: text("operating_system"),
|
||||
operatingSystemVersion: text("operating_system_version"),
|
||||
lastLogon: timestamp("last_logon"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
adProperties: jsonb("ad_properties"),
|
||||
});
|
||||
|
||||
export const adDomains = pgTable("ad_domains", {
|
||||
id: serial("id").primaryKey(),
|
||||
connectionId: integer("connection_id").notNull(),
|
||||
distinguishedName: text("distinguished_name").notNull(),
|
||||
name: text("name").notNull(),
|
||||
netBIOSName: text("net_bios_name"),
|
||||
forestName: text("forest_name"),
|
||||
domainFunctionality: text("domain_functionality"),
|
||||
adProperties: jsonb("ad_properties"),
|
||||
});
|
||||
|
||||
// Generate insertion schemas
|
||||
export const insertUserSchema = createInsertSchema(users).omit({ id: true, createdAt: true });
|
||||
export const insertApiTokenSchema = createInsertSchema(apiTokens).omit({ id: true, createdAt: true });
|
||||
export const insertLdapConnectionSchema = createInsertSchema(ldapConnections).omit({ id: true, createdAt: true, lastConnected: true });
|
||||
export const insertAdUserSchema = createInsertSchema(adUsers).omit({ id: true });
|
||||
export const insertAdGroupSchema = createInsertSchema(adGroups).omit({ id: true });
|
||||
export const insertAdOrgUnitSchema = createInsertSchema(adOrgUnits).omit({ id: true });
|
||||
export const insertAdComputerSchema = createInsertSchema(adComputers).omit({ id: true });
|
||||
export const insertAdDomainSchema = createInsertSchema(adDomains).omit({ id: true });
|
||||
|
||||
// Login schema
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
// API query parameters schema
|
||||
export const apiQuerySchema = z.object({
|
||||
filter: z.string().optional(),
|
||||
select: z.string().optional(),
|
||||
expand: z.string().optional(),
|
||||
orderBy: z.string().optional(),
|
||||
top: z.string().optional(),
|
||||
skip: z.string().optional(),
|
||||
});
|
||||
|
||||
// Export types
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||
|
||||
export type ApiToken = typeof apiTokens.$inferSelect;
|
||||
export type InsertApiToken = z.infer<typeof insertApiTokenSchema>;
|
||||
|
||||
export type LdapConnection = typeof ldapConnections.$inferSelect;
|
||||
export type InsertLdapConnection = z.infer<typeof insertLdapConnectionSchema>;
|
||||
|
||||
export type ActivityLog = typeof activityLogs.$inferSelect;
|
||||
export type InsertActivityLog = z.infer<typeof insertActivityLogSchema>;
|
||||
export type AdUser = typeof adUsers.$inferSelect;
|
||||
export type InsertAdUser = z.infer<typeof insertAdUserSchema>;
|
||||
export type AdGroup = typeof adGroups.$inferSelect;
|
||||
export type InsertAdGroup = z.infer<typeof insertAdGroupSchema>;
|
||||
export type AdOrgUnit = typeof adOrgUnits.$inferSelect;
|
||||
export type InsertAdOrgUnit = z.infer<typeof insertAdOrgUnitSchema>;
|
||||
export type AdComputer = typeof adComputers.$inferSelect;
|
||||
export type InsertAdComputer = z.infer<typeof insertAdComputerSchema>;
|
||||
export type AdDomain = typeof adDomains.$inferSelect;
|
||||
export type InsertAdDomain = z.infer<typeof insertAdDomainSchema>;
|
||||
export type Login = z.infer<typeof loginSchema>;
|
||||
export type ApiQuery = z.infer<typeof apiQuerySchema>;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"variant": "professional",
|
||||
"primary": "hsl(210 79% 46%)",
|
||||
"primary": "hsl(222.2 47.4% 11.2%)",
|
||||
"appearance": "light",
|
||||
"radius": 0.5
|
||||
"radius": 0.25
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user