diff --git a/.replit b/.replit index 82b8e35..290a8b6 100644 --- a/.replit +++ b/.replit @@ -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 diff --git a/client/src/App.tsx b/client/src/App.tsx index 7e1c03d..0b24e82 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( - {/* Add pages below */} - {/* */} - {/* Fallback to 404 */} + + + + + + + + + + + + + ); @@ -17,10 +37,10 @@ function Router() { function App() { return ( - + <> - + ); } diff --git a/client/src/components/dashboard/api-activity-card.tsx b/client/src/components/dashboard/api-activity-card.tsx new file mode 100644 index 0000000..08e62f6 --- /dev/null +++ b/client/src/components/dashboard/api-activity-card.tsx @@ -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 ( + + + API Activity + + +
+
+
+
+
+
+
75%
+
+ +
+ + + + + + + + + + + + {apiRequests.map((request, index) => ( + + + + + + + + ))} + +
EndpointMethodRequestsSuccessAvg. Time
{request.endpoint} + + {request.method} + + {request.requests.toLocaleString()}{request.success}%{request.avgTime}ms
+
+
+
+ ); +} diff --git a/client/src/components/dashboard/api-documentation-card.tsx b/client/src/components/dashboard/api-documentation-card.tsx new file mode 100644 index 0000000..f8d2075 --- /dev/null +++ b/client/src/components/dashboard/api-documentation-card.tsx @@ -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 ( + + + API Documentation + + + +
+
+# 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
+
+
+ +
+ +
+
+
+ ); +} diff --git a/client/src/components/dashboard/api-tokens-card.tsx b/client/src/components/dashboard/api-tokens-card.tsx new file mode 100644 index 0000000..7c6e3b5 --- /dev/null +++ b/client/src/components/dashboard/api-tokens-card.tsx @@ -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(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 ( + + + Active API Tokens + + + +
+ {tokens.length === 0 ? ( +
+ No active API tokens. Create a token to get started. +
+ ) : ( + tokens.slice(0, 3).map((token) => ( +
+
+
{token.name}
+
+ Created {getCreatedTime(token.createdAt)} +
+
+ +
+ )) + )} + + {tokens.length > 3 && ( + + )} +
+ +
+
Token Expiration Policy
+
+
+ +
+ +
+
+
+ + setIsCreateModalOpen(false)} + /> + + setTokenToDelete(null)}> + + + Revoke API Token + + 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. + + + + Cancel + + Revoke + + + + +
+ ); +} diff --git a/client/src/components/dashboard/ldap-connections-card.tsx b/client/src/components/dashboard/ldap-connections-card.tsx new file mode 100644 index 0000000..7709b21 --- /dev/null +++ b/client/src/components/dashboard/ldap-connections-card.tsx @@ -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(null); + const [connectionToDelete, setConnectionToDelete] = useState(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 ( + + + LDAP Connections + + + +
+ {connections.length === 0 ? ( +
+ No LDAP connections configured. Add a connection to get started. +
+ ) : ( + + + + + + + + + + + + + + {connections.map((connection, index) => ( + + + + + + + + + + ))} + +
Connection NameServerDomainPortSSLStatusActions
{connection.name}{connection.server}{connection.domain}{connection.port}{connection.useSSL ? "Yes" : "No"} + + {connection.status === "connected" ? "Connected" : "Disconnected"} + + +
+ + +
+
+ )} +
+
+ + { + setIsAddModalOpen(false); + setConnectionToEdit(null); + }} + connectionToEdit={connectionToEdit} + /> + + setConnectionToDelete(null)}> + + + Delete LDAP Connection + + Are you sure you want to delete the LDAP connection "{connectionToDelete?.name}"? + This action cannot be undone. + + + + Cancel + + Delete + + + + +
+ ); +} diff --git a/client/src/components/dashboard/stats-card.tsx b/client/src/components/dashboard/stats-card.tsx new file mode 100644 index 0000000..2f845c1 --- /dev/null +++ b/client/src/components/dashboard/stats-card.tsx @@ -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 ( + + +
+
+

{title}

+

{value.toLocaleString()}

+
+
+ {icon} +
+
+
+ {changeType === "increase" && ( + <> + + {changeValue}% increase + {changePeriod} + + )} + {changeType === "decrease" && ( + <> + + {changeValue}% decrease + {changePeriod} + + )} + {changeType === "nochange" && ( + <> + No change + {changePeriod} + + )} +
+
+
+ ); +} diff --git a/client/src/components/modals/add-ldap-modal.tsx b/client/src/components/modals/add-ldap-modal.tsx new file mode 100644 index 0000000..65b508b --- /dev/null +++ b/client/src/components/modals/add-ldap-modal.tsx @@ -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; + +export function AddLdapModal({ isOpen, onClose, connectionToEdit }: AddLdapModalProps) { + const { toast } = useToast(); + + const form = useForm({ + 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 ( + + + + + {connectionToEdit ? "Edit LDAP Connection" : "Add LDAP Connection"} + + + {connectionToEdit + ? "Update your Active Directory connection settings" + : "Configure a new connection to your Active Directory server"} + + + +
+ + ( + + Connection Name + + + + + + )} + /> + + ( + + Server + + + + + + )} + /> + + ( + + Domain + + + + + + )} + /> + +
+ ( + + Port + + field.onChange(Number(e.target.value))} + /> + + + + )} + /> + + ( + + SSL + + + + )} + /> +
+ + ( + + Username + + + + + + )} + /> + + ( + + + {connectionToEdit ? "Password (leave blank to keep current)" : "Password"} + + + + + + + )} + /> + + + + + + + + + + +
+
+ ); +} diff --git a/client/src/components/modals/create-token-modal.tsx b/client/src/components/modals/create-token-modal.tsx new file mode 100644 index 0000000..51a212c --- /dev/null +++ b/client/src/components/modals/create-token-modal.tsx @@ -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; + +export function CreateTokenModal({ isOpen, onClose }: CreateTokenModalProps) { + const { toast } = useToast(); + const [createdToken, setCreatedToken] = useState(null); + + const form = useForm({ + 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 ( + + + {createdToken ? ( + <> + + API Token Created + + Copy your API token now. For security reasons, it won't be shown again. + + + +
+ + + Important + + Please copy and store this token securely. It will not be displayed again. + + +
+ + + +
+
+ {createdToken} +
+ +
+
+
+ + + + + + ) : ( + <> + + Create API Token + + Create a new token to access the AD Management API + + + +
+ + ( + + Token Name + + + + + + )} + /> + + ( + + Expiration + + + + )} + /> + +
+ Permissions +
+
+ {permissionItems.map((item) => ( + ( + + + + + + {item.label} + + + )} + /> + ))} +
+
+
+ + + + + + + + + )} +
+
+ ); +} diff --git a/client/src/components/ui/data-table.tsx b/client/src/components/ui/data-table.tsx new file mode 100644 index 0000000..c3bef52 --- /dev/null +++ b/client/src/components/ui/data-table.tsx @@ -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 { + header: string; + accessorKey: keyof T | ((row: T) => React.ReactNode); + cell?: (row: T) => React.ReactNode; +} + +interface DataTableProps { + data: T[]; + columns: DataTableColumn[]; + 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({ + data, + columns, + isLoading = false, + onRowClick, + searchable = false, + filterable = false, + pagination, +}: DataTableProps) { + 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).some(([key, value]) => { + if (typeof value === 'string') { + return value.toLowerCase().includes(searchTerm.toLowerCase()); + } + return false; + }); + }); + }, [data, searchTerm]); + + return ( +
+ {(searchable || filterable) && ( +
+ {searchable && ( +
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+ )} + {filterable && ( + + )} +
+ )} + +
+ + + + {columns.map((column, i) => ( + + {column.header} + + ))} + + + + {isLoading ? ( + + + Loading... + + + ) : filteredData.length === 0 ? ( + + + No results found. + + + ) : ( + filteredData.map((row, i) => ( + onRowClick?.(row)} + className={onRowClick ? "cursor-pointer hover:bg-muted/50" : undefined} + > + {columns.map((column, j) => ( + + {column.cell + ? column.cell(row) + : typeof column.accessorKey === "function" + ? column.accessorKey(row) + : (row[column.accessorKey] as React.ReactNode)} + + ))} + + )) + )} + +
+
+ + {pagination && ( +
+
+

+ Rows per page: +

+ +
+ +
+
+ Page {pagination.pageIndex + 1} of {pagination.pageCount} +
+
+ + + + +
+
+
+ )} +
+ ); +} diff --git a/client/src/components/ui/status-badge.tsx b/client/src/components/ui/status-badge.tsx new file mode 100644 index 0000000..1dd012f --- /dev/null +++ b/client/src/components/ui/status-badge.tsx @@ -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 ( + + {children} + + ); +} diff --git a/client/src/hooks/use-auth.tsx b/client/src/hooks/use-auth.tsx new file mode 100644 index 0000000..c314fee --- /dev/null +++ b/client/src/hooks/use-auth.tsx @@ -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; + logoutMutation: UseMutationResult; + registerMutation: UseMutationResult; +}; + +type LoginData = Pick; + +export const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const { toast } = useToast(); + const { + data: user, + error, + isLoading, + } = useQuery({ + 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 ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/client/src/hooks/use-mobile.tsx b/client/src/hooks/use-mobile.tsx index 2b0fe1d..cc2e51a 100644 --- a/client/src/hooks/use-mobile.tsx +++ b/client/src/hooks/use-mobile.tsx @@ -2,7 +2,7 @@ import * as React from "react" const MOBILE_BREAKPOINT = 768 -export function useIsMobile() { +export function useMobile() { const [isMobile, setIsMobile] = React.useState(undefined) React.useEffect(() => { diff --git a/client/src/index.css b/client/src/index.css index e0ce862..0cf721b 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; } -} \ No newline at end of file + + .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; + } +} diff --git a/client/src/layouts/dashboard-layout.tsx b/client/src/layouts/dashboard-layout.tsx new file mode 100644 index 0000000..cb754dd --- /dev/null +++ b/client/src/layouts/dashboard-layout.tsx @@ -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: }, + { title: "Groups", path: "/groups", icon: }, + { title: "Organizational Units", path: "/organizational-units", icon: }, + { title: "Computers", path: "/computers", icon: }, + { title: "Domains", path: "/domains", icon: }, + ], + }, + { + title: "Administration", + items: [ + { title: "API Tokens", path: "/api-tokens", icon: }, + { title: "LDAP Connections", path: "/ldap-connections", icon: }, + { title: "Settings", path: "/settings", icon: }, + { title: "User Management", path: "/user-management", icon: }, + ], + }, +]; + +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 ( +
+ {/* Sidebar */} +
+ + +
+
+ + + + Dashboard + + + + {menuSections.map((section, idx) => ( +
+
+ {section.title} +
+ + {section.items.map((item, itemIdx) => ( + + + {item.icon} + {item.title} + + + ))} +
+ ))} +
+
+
+ + {/* Main Content */} +
+ {/* Top App Bar */} +
+
+ +
+ +
+ + + + + + + + + + + + My Account + + + + Profile + + + + Settings + + + + + Log out + + + +
+
+ + {/* Main Content Area */} +
+
+

{title}

+ {description &&

{description}

} +
+ + {children} +
+
+
+ ); +} diff --git a/client/src/lib/protected-route.tsx b/client/src/lib/protected-route.tsx new file mode 100644 index 0000000..d2b7312 --- /dev/null +++ b/client/src/lib/protected-route.tsx @@ -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 ( + +
+ +
+
+ ); + } + + if (!user) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 696e0d2..cf28a4f 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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(); +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/client/src/pages/api-tokens-page.tsx b/client/src/pages/api-tokens-page.tsx new file mode 100644 index 0000000..a2513e9 --- /dev/null +++ b/client/src/pages/api-tokens-page.tsx @@ -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(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data: tokens = [], isLoading } = useQuery({ + 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) => ( +
+ +
+ ), + }, + ]; + + return ( + +
+
+

+ Create and manage API tokens for accessing the AD Management API programmatically. +

+
+ +
+ + + + setIsCreateModalOpen(false)} + /> + + setTokenToDelete(null)}> + + + Revoke API Token + + 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. + + + + Cancel + + Revoke + + + + +
+ ); +} diff --git a/client/src/pages/auth-page.tsx b/client/src/pages/auth-page.tsx new file mode 100644 index 0000000..e455458 --- /dev/null +++ b/client/src/pages/auth-page.tsx @@ -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; + +// 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; + +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({ + resolver: zodResolver(loginSchema), + defaultValues: { + username: "", + password: "", + rememberMe: false, + }, + }); + + // Register form + const registerForm = useForm({ + 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 ( +
+
+
+
+

Active Directory Management API

+

+ A comprehensive solution for managing your Active Directory resources +

+
+ + + + Login + Register + + + + + + Login to your account + + Enter your credentials to access the dashboard + + + +
+ + ( + + Username + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + +
+ ( + + + + + + Remember me + + + )} + /> + + +
+ + + + +
+
+
+ + + + + Create a new account + + Fill out the form to register a new account + + + +
+ + ( + + Username + + + + + + )} + /> + + ( + + Full Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + ( + + + + +
+ + I accept the terms and conditions + + +
+
+ )} + /> + + + + +
+
+
+
+
+
+ +
+
+
+

Active Directory Management API

+
    +
  • + ✓ + Complete CRUD operations for AD resources +
  • +
  • + ✓ + Comprehensive API with Swagger documentation +
  • +
  • + ✓ + Flexible filtering and property selection +
  • +
  • + ✓ + Secure API token management +
  • +
  • + ✓ + Intuitive and responsive admin interface +
  • +
+
+
+
+
+ ); +} diff --git a/client/src/pages/computers-page.tsx b/client/src/pages/computers-page.tsx new file mode 100644 index 0000000..2137c72 --- /dev/null +++ b/client/src/pages/computers-page.tsx @@ -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(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + }); + + const { data: computers = [], isLoading: isLoadingComputers } = useQuery({ + 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 ? + Enabled : + Disabled + ), + }, + { + header: "Last Logon", + accessorKey: "lastLogon", + cell: (row: AdComputer) => row.lastLogon ? format(new Date(row.lastLogon), 'MMM dd, yyyy') : 'Never', + }, + ]; + + return ( + +
+
+ {connections.map((connection) => ( + setSelectedConnection(connection.id)} + > + {connection.name} + + ))} + {connections.length === 0 && !isLoadingConnections && ( +
+ No LDAP connections available. Add a connection first. +
+ )} +
+
+ + + +
+
+ + +
+ ); +} diff --git a/client/src/pages/dashboard-page.tsx b/client/src/pages/dashboard-page.tsx new file mode 100644 index 0000000..bd2beef --- /dev/null +++ b/client/src/pages/dashboard-page.tsx @@ -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({ + queryKey: ["/api/ldap-connections"], + }); + + const { data: tokens = [] } = useQuery({ + queryKey: ["/api/tokens"], + }); + + return ( + + {/* Statistics Cards */} +
+ } + iconBgColor="bg-blue-100" + iconColor="text-primary" + changeValue={4.75} + changeType="increase" + changePeriod="from last month" + /> + + } + iconBgColor="bg-purple-100" + iconColor="text-purple-600" + changeValue={2.3} + changeType="increase" + changePeriod="from last month" + /> + + } + iconBgColor="bg-amber-100" + iconColor="text-amber-600" + changeValue={0} + changeType="nochange" + changePeriod="from last month" + /> + + } + iconBgColor="bg-teal-100" + iconColor="text-teal-600" + changeValue={1.2} + changeType="decrease" + changePeriod="from last month" + /> +
+ +
+ {/* API Activity Card */} +
+ +
+ + {/* Active API Tokens */} +
+ +
+
+ + {/* LDAP Connections */} +
+ +
+ + {/* API Documentation Preview */} +
+ +
+
+ ); +} diff --git a/client/src/pages/domains-page.tsx b/client/src/pages/domains-page.tsx new file mode 100644 index 0000000..f60685a --- /dev/null +++ b/client/src/pages/domains-page.tsx @@ -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(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + }); + + const { data: domains = [], isLoading: isLoadingDomains } = useQuery({ + 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) => ( +
+ {row.distinguishedName} +
+ ), + }, + ]; + + return ( + +
+
+ {connections.map((connection) => ( + setSelectedConnection(connection.id)} + > + {connection.name} + + ))} + {connections.length === 0 && !isLoadingConnections && ( +
+ No LDAP connections available. Add a connection first. +
+ )} +
+
+ + + +
+
+ + +
+ ); +} diff --git a/client/src/pages/groups-page.tsx b/client/src/pages/groups-page.tsx new file mode 100644 index 0000000..c42eb2f --- /dev/null +++ b/client/src/pages/groups-page.tsx @@ -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(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + }); + + const { data: groups = [], isLoading: isLoadingGroups } = useQuery({ + 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) => ( +
+ {row.distinguishedName} +
+ ), + }, + ]; + + return ( + +
+
+ {connections.map((connection) => ( + setSelectedConnection(connection.id)} + > + {connection.name} + + ))} + {connections.length === 0 && !isLoadingConnections && ( +
+ No LDAP connections available. Add a connection first. +
+ )} +
+
+ + + +
+
+ + +
+ ); +} diff --git a/client/src/pages/ldap-connections-page.tsx b/client/src/pages/ldap-connections-page.tsx new file mode 100644 index 0000000..a4d156d --- /dev/null +++ b/client/src/pages/ldap-connections-page.tsx @@ -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(null); + const [connectionToDelete, setConnectionToDelete] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data: connections = [], isLoading } = useQuery({ + 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) => ( + + {row.status === "connected" ? "Connected" : "Disconnected"} + + ), + }, + { + 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) => ( +
+ + +
+ ), + }, + ]; + + return ( + +
+
+

+ Set up and manage connections to your Active Directory servers. +

+
+ +
+ + + + { + setIsAddModalOpen(false); + setConnectionToEdit(null); + }} + connectionToEdit={connectionToEdit} + /> + + setConnectionToDelete(null)}> + + + Delete LDAP Connection + + Are you sure you want to delete the LDAP connection "{connectionToDelete?.name}"? + This action cannot be undone. + + + + Cancel + + Delete + + + + +
+ ); +} diff --git a/client/src/pages/ous-page.tsx b/client/src/pages/ous-page.tsx new file mode 100644 index 0000000..51ebc11 --- /dev/null +++ b/client/src/pages/ous-page.tsx @@ -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(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + }); + + const { data: orgUnits = [], isLoading: isLoadingOUs } = useQuery({ + 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) => ( +
+ {row.distinguishedName} +
+ ), + }, + ]; + + return ( + +
+
+ {connections.map((connection) => ( + setSelectedConnection(connection.id)} + > + {connection.name} + + ))} + {connections.length === 0 && !isLoadingConnections && ( +
+ No LDAP connections available. Add a connection first. +
+ )} +
+
+ + + +
+
+ + +
+ ); +} diff --git a/client/src/pages/settings-page.tsx b/client/src/pages/settings-page.tsx new file mode 100644 index 0000000..ac1cdc8 --- /dev/null +++ b/client/src/pages/settings-page.tsx @@ -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 ( + + + + General + Security + API + Notifications + + + +
+ + + General Settings + + Configure general application settings + + + +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ Show contextual help and tooltips throughout the application +
+
+ +
+
+
+
+
+ + +
+ + + Security Settings + + Configure security related settings + + + +
+ + +
+ +
+
+ +
+ Require two-factor authentication for all users +
+
+ +
+ +
+
+ +
+ Enforce HTTPS for all connections +
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+ + + API Settings + + Configure API related settings + + + +
+ + +
+ +
+ + +
+ +
+
+ +
+ Make Swagger documentation publicly accessible +
+
+ +
+ +
+
+ +
+ Enable Cross-Origin Resource Sharing +
+
+ +
+ +
+ + +

+ Comma-separated list of allowed origins, or * for all origins +

+
+
+
+
+
+ + +
+ + + Notification Settings + + Configure how notifications are handled + + + +
+
+ +
+ Send notifications via email +
+
+ +
+ +
+ + +
+ + + +
+

Notification Types

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/client/src/pages/user-management-page.tsx b/client/src/pages/user-management-page.tsx new file mode 100644 index 0000000..402571d --- /dev/null +++ b/client/src/pages/user-management-page.tsx @@ -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; + +export default function UserManagementPage() { + const { toast } = useToast(); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [userToEdit, setUserToEdit] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const form = useForm({ + resolver: zodResolver(userFormSchema), + defaultValues: { + username: "", + password: "", + confirmPassword: "", + email: "", + fullName: "", + role: "user", + }, + }); + + const { data: users = [], isLoading } = useQuery({ + 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) => ( + + {row.role || "user"} + + ), + }, + { + header: "Created", + accessorKey: "createdAt", + cell: (row: User) => row.createdAt ? format(new Date(row.createdAt), 'MMM dd, yyyy') : '', + }, + { + header: "Actions", + accessorKey: (row: User) => ( +
+ + {row.role === "admin" ? ( + + ) : ( + + )} + +
+ ), + }, + ]; + + return ( + +
+
+

+ Manage users who can access the AD Management admin interface. +

+
+ +
+ + + + + + + {userToEdit ? "Edit User" : "Add New User"} + + {userToEdit + ? "Update user information and permissions." + : "Fill in the details to create a new user."} + + + +
+ + ( + + Username + + + + + + )} + /> + + ( + + Full Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Role + + + + + + )} + /> + + ( + + {userToEdit ? "New Password (optional)" : "Password"} + + + + + + )} + /> + + ( + + {userToEdit ? "Confirm New Password" : "Confirm Password"} + + + + + + )} + /> + + + + + + + +
+
+ + setUserToDelete(null)}> + + + Delete User + + Are you sure you want to delete the user "{userToDelete?.username}"? + This action cannot be undone. + + + + Cancel + + Delete + + + + +
+ ); +} diff --git a/client/src/pages/users-page.tsx b/client/src/pages/users-page.tsx new file mode 100644 index 0000000..640eea3 --- /dev/null +++ b/client/src/pages/users-page.tsx @@ -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(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const { data: connections = [], isLoading: isLoadingConnections } = useQuery({ + queryKey: ["/api/ldap-connections"], + }); + + const { data: users = [], isLoading: isLoadingUsers } = useQuery({ + 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 ? + Enabled : + Disabled + ), + }, + { + header: "Last Logon", + accessorKey: "lastLogon", + cell: (row: AdUser) => row.lastLogon ? format(new Date(row.lastLogon), 'MMM dd, yyyy') : 'Never', + }, + ]; + + return ( + +
+
+ {connections.map((connection) => ( + setSelectedConnection(connection.id)} + > + {connection.name} + + ))} + {connections.length === 0 && !isLoadingConnections && ( +
+ No LDAP connections available. Add a connection first. +
+ )} +
+
+ + + +
+
+ + +
+ ); +} diff --git a/package-lock.json b/package-lock.json index 803ffb8..adb3b9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 31ded10..bcc5ad2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/auth.ts b/server/auth.ts index c9bdea8..5e93955 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -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 }; } diff --git a/server/routes.ts b/server/routes.ts index 9da3a81..4de0dcf 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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 { - // 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); diff --git a/server/storage.ts b/server/storage.ts index 05a1173..e37b0ed 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -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; getUserByUsername(username: string): Promise; createUser(user: InsertUser): Promise; updateUser(id: number, user: Partial): Promise; deleteUser(id: number): Promise; - listUsers(filters?: any): Promise; + listUsers(): Promise; - // API Tokens + // API Token management getApiToken(id: number): Promise; getApiTokenByToken(token: string): Promise; - createApiToken(token: InsertApiToken & { token: string }): Promise; - updateApiToken(id: number, token: Partial): Promise; + createApiToken(token: InsertApiToken): Promise; deleteApiToken(id: number): Promise; - listApiTokens(userId?: number): Promise; + listApiTokensByUserId(userId: number): Promise; - // LDAP Connections + // LDAP Connection management getLdapConnection(id: number): Promise; createLdapConnection(connection: InsertLdapConnection): Promise; updateLdapConnection(id: number, connection: Partial): Promise; deleteLdapConnection(id: number): Promise; listLdapConnections(): Promise; - // Activity Logs - createActivityLog(log: InsertActivityLog): Promise; - listActivityLogs(limit?: number): Promise; + // AD Users + getAdUser(id: number): Promise; + createAdUser(user: InsertAdUser): Promise; + updateAdUser(id: number, user: Partial): Promise; + deleteAdUser(id: number): Promise; + listAdUsers(connectionId: number, query?: any): Promise; - // Session Store + // AD Groups + getAdGroup(id: number): Promise; + createAdGroup(group: InsertAdGroup): Promise; + updateAdGroup(id: number, group: Partial): Promise; + deleteAdGroup(id: number): Promise; + listAdGroups(connectionId: number, query?: any): Promise; + + // AD Organizational Units + getAdOrgUnit(id: number): Promise; + createAdOrgUnit(ou: InsertAdOrgUnit): Promise; + updateAdOrgUnit(id: number, ou: Partial): Promise; + deleteAdOrgUnit(id: number): Promise; + listAdOrgUnits(connectionId: number, query?: any): Promise; + + // AD Computers + getAdComputer(id: number): Promise; + createAdComputer(computer: InsertAdComputer): Promise; + updateAdComputer(id: number, computer: Partial): Promise; + deleteAdComputer(id: number): Promise; + listAdComputers(connectionId: number, query?: any): Promise; + + // AD Domains + getAdDomain(id: number): Promise; + createAdDomain(domain: InsertAdDomain): Promise; + updateAdDomain(id: number, domain: Partial): Promise; + deleteAdDomain(id: number): Promise; + listAdDomains(connectionId: number, query?: any): Promise; + + // Session store sessionStore: session.SessionStore; } @@ -46,31 +78,47 @@ export class MemStorage implements IStorage { private users: Map; private apiTokens: Map; private ldapConnections: Map; - private activityLogs: ActivityLog[]; - - currentUserId: number; - currentTokenId: number; - currentConnectionId: number; - currentLogId: number; + private adUsers: Map; + private adGroups: Map; + private adOrgUnits: Map; + private adComputers: Map; + private adDomains: Map; 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 { return this.users.get(id); } @@ -82,18 +130,23 @@ export class MemStorage implements IStorage { } async createUser(insertUser: InsertUser): Promise { - 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): Promise { - const user = this.users.get(id); + async updateUser(id: number, userData: Partial): Promise { + 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 { - 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 { + return Array.from(this.users.values()); } - // API Tokens + // API Token management async getApiToken(id: number): Promise { return this.apiTokens.get(id); } @@ -128,61 +170,48 @@ export class MemStorage implements IStorage { ); } - async createApiToken(tokenData: InsertApiToken & { token: string }): Promise { - 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): Promise { - 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 { + 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 { return this.apiTokens.delete(id); } - async listApiTokens(userId?: number): Promise { - const tokens = Array.from(this.apiTokens.values()); - if (userId === undefined) return tokens; - - return tokens.filter(token => token.userId === userId); + async listApiTokensByUserId(userId: number): Promise { + return Array.from(this.apiTokens.values()).filter( + (token) => token.userId === userId, + ); } - // LDAP Connections + // LDAP Connection management async getLdapConnection(id: number): Promise { return this.ldapConnections.get(id); } - async createLdapConnection(connectionData: InsertLdapConnection): Promise { - const id = this.currentConnectionId++; - const ldapConnection: LdapConnection = { - ...connectionData, + async createLdapConnection(insertConnection: InsertLdapConnection): Promise { + 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): Promise { - const connection = this.ldapConnections.get(id); + async updateLdapConnection(id: number, connectionData: Partial): Promise { + 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 { - 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 { + return this.adUsers.get(id); } - async listActivityLogs(limit = 10): Promise { - // 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 { + const id = this.adUserCurrentId++; + const adUser: AdUser = { ...user, id }; + this.adUsers.set(id, adUser); + return adUser; + } + + async updateAdUser(id: number, userData: Partial): Promise { + 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 { + return this.adUsers.delete(id); + } + + async listAdUsers(connectionId: number, query?: any): Promise { + 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 { + return this.adGroups.get(id); + } + + async createAdGroup(group: InsertAdGroup): Promise { + const id = this.adGroupCurrentId++; + const adGroup: AdGroup = { ...group, id }; + this.adGroups.set(id, adGroup); + return adGroup; + } + + async updateAdGroup(id: number, groupData: Partial): Promise { + 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 { + return this.adGroups.delete(id); + } + + async listAdGroups(connectionId: number, query?: any): Promise { + 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 { + return this.adOrgUnits.get(id); + } + + async createAdOrgUnit(ou: InsertAdOrgUnit): Promise { + const id = this.adOrgUnitCurrentId++; + const adOrgUnit: AdOrgUnit = { ...ou, id }; + this.adOrgUnits.set(id, adOrgUnit); + return adOrgUnit; + } + + async updateAdOrgUnit(id: number, ouData: Partial): Promise { + 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 { + return this.adOrgUnits.delete(id); + } + + async listAdOrgUnits(connectionId: number, query?: any): Promise { + 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 { + return this.adComputers.get(id); + } + + async createAdComputer(computer: InsertAdComputer): Promise { + const id = this.adComputerCurrentId++; + const adComputer: AdComputer = { ...computer, id }; + this.adComputers.set(id, adComputer); + return adComputer; + } + + async updateAdComputer(id: number, computerData: Partial): Promise { + 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 { + return this.adComputers.delete(id); + } + + async listAdComputers(connectionId: number, query?: any): Promise { + 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 { + return this.adDomains.get(id); + } + + async createAdDomain(domain: InsertAdDomain): Promise { + const id = this.adDomainCurrentId++; + const adDomain: AdDomain = { ...domain, id }; + this.adDomains.set(id, adDomain); + return adDomain; + } + + async updateAdDomain(id: number, domainData: Partial): Promise { + 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 { + return this.adDomains.delete(id); + } + + async listAdDomains(connectionId: number, query?: any): Promise { + 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; } } diff --git a/server/swagger.ts b/server/swagger.ts index ffa4344..16d13c8 100644 --- a/server/swagger.ts +++ b/server/swagger.ts @@ -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); }); -}; +} diff --git a/shared/schema.ts b/shared/schema.ts index 5c695ec..ca48c23 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -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; - export type ApiToken = typeof apiTokens.$inferSelect; export type InsertApiToken = z.infer; - export type LdapConnection = typeof ldapConnections.$inferSelect; export type InsertLdapConnection = z.infer; - -export type ActivityLog = typeof activityLogs.$inferSelect; -export type InsertActivityLog = z.infer; +export type AdUser = typeof adUsers.$inferSelect; +export type InsertAdUser = z.infer; +export type AdGroup = typeof adGroups.$inferSelect; +export type InsertAdGroup = z.infer; +export type AdOrgUnit = typeof adOrgUnits.$inferSelect; +export type InsertAdOrgUnit = z.infer; +export type AdComputer = typeof adComputers.$inferSelect; +export type InsertAdComputer = z.infer; +export type AdDomain = typeof adDomains.$inferSelect; +export type InsertAdDomain = z.infer; +export type Login = z.infer; +export type ApiQuery = z.infer; diff --git a/theme.json b/theme.json index 3f6b5d1..a1b6b2d 100644 --- a/theme.json +++ b/theme.json @@ -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 }