diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index e44e6774..63398e72 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources, sites, targets } from "@server/db/schema"; +import { resources, sites, Target, targets } from "@server/db/schema"; import response from "@server/utils/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -23,6 +23,8 @@ const createTargetSchema = z.object({ enabled: z.boolean().default(true), }); +export type CreateTargetResponse = Target; + export async function createTarget( req: Request, res: Response, @@ -126,7 +128,7 @@ export async function createTarget( allowedIps: targetIps.flat(), }); - return response(res, { + return response(res, { data: newTarget[0], success: true, error: false, diff --git a/src/app/[orgId]/settings/page.tsx b/src/app/[orgId]/settings/page.tsx index 9956bc85..142e73ae 100644 --- a/src/app/[orgId]/settings/page.tsx +++ b/src/app/[orgId]/settings/page.tsx @@ -6,7 +6,7 @@ type OrgPageProps = { export default async function SettingsPage(props: OrgPageProps) { const params = await props.params; - redirect(`/${params.orgId}/settings/sites`); + redirect(`/${params.orgId}/settings/resources`); return <>; } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx new file mode 100644 index 00000000..648d8112 --- /dev/null +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { useEffect, useState, use } from "react"; +import { Trash2, Server, Globe, Cpu } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import api from "@app/api"; +import { AxiosResponse } from "axios"; +import { ListTargetsResponse } from "@server/routers/target/listTargets"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@app/components/ui/form"; +import { CreateTargetResponse, updateTarget } from "@server/routers/target"; +import { + ColumnDef, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + getCoreRowModel, + useReactTable, + flexRender, +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@app/components/ui/table"; +import { useToast } from "@app/hooks/useToast"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Target } from "@server/db/schema"; +import { useResourceContext } from "@app/hooks/useResourceContext"; + +const addTargetSchema = z.object({ + ip: z + .string() + .regex( + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/, + "Invalid IP address format" + ), + method: z.string(), + port: z + .string() + .refine((val) => !isNaN(Number(val)), { + message: "Port must be a number", + }) + .transform((val) => Number(val)), + protocol: z.string(), +}); + +type AddTargetFormValues = z.infer; + +export default function ReverseProxyTargets(props: { + params: Promise<{ resourceId: number }>; +}) { + const params = use(props.params); + + const { toast } = useToast(); + const { resource, updateResource } = useResourceContext(); + + const [targets, setTargets] = useState([]); + + const addTargetForm = useForm({ + resolver: zodResolver(addTargetSchema), + defaultValues: { + ip: "", + method: "http", + port: "80", + protocol: "TCP", + }, + }); + + useEffect(() => { + const fetchSites = async () => { + const res = await api + .get>( + `/resource/${params.resourceId}/targets` + ) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to fetch targets", + description: + err.message || + "An error occurred while fetching targets", + }); + }); + + if (res && res.status === 200) { + setTargets(res.data.data.targets); + } + }; + fetchSites(); + }, []); + + async function addTarget(data: AddTargetFormValues) { + const res = await api + .put>( + `/resource/${params.resourceId}/target`, + { + ...data, + resourceId: undefined, + } + ) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to add target", + description: + err.message || "An error occurred while adding target", + }); + }); + + if (res && res.status === 201) { + setTargets([...targets, res.data.data]); + addTargetForm.reset(); + } + } + + const removeTarget = (targetId: number) => { + api.delete(`/target/${targetId}`) + .catch((err) => { + console.error(err); + }) + .then((res) => { + setTargets( + targets.filter((target) => target.targetId !== targetId) + ); + }); + }; + + async function updateTarget(targetId: number, data: Partial) { + setTargets( + targets.map((target) => + target.targetId === targetId ? { ...target, ...data } : target + ) + ); + + const res = await api.post(`/target/${targetId}`, data).catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to update target", + description: + err.message || "An error occurred while updating target", + }); + }); + + if (res && res.status === 200) { + toast({ + title: "Target updated", + description: "The target has been updated successfully", + }); + } + } + + const columns: ColumnDef[] = [ + { + accessorKey: "ip", + header: "IP Address", + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { + ip: e.target.value, + }) + } + /> + ), + }, + { + accessorKey: "port", + header: "Port", + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { + port: parseInt(e.target.value, 10), + }) + } + /> + ), + }, + { + accessorKey: "method", + header: "Method", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "protocol", + header: "Protocol", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "enabled", + header: "Enabled", + cell: ({ row }) => ( + + updateTarget(row.original.targetId, { enabled: val }) + } + /> + ), + }, + { + id: "actions", + cell: ({ row }) => ( + + ), + }, + ]; + + const table = useReactTable({ + data: targets, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + return ( +
+ {/*
*/} +
+
+ + +
+ + +
+
+ +
+ + +
+ +
+ ( + + IP Address + + + + + Enter the IP address of the + target + + + + )} + /> + ( + + Method + + + + + Choose the method for the target + connection + + + + )} + /> + ( + + Port + + + + + Specify the port number for the + target + + + + )} + /> + ( + + Protocol + + + + + Select the protocol used by the + target + + + + )} + /> +
+ +
+ +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index a745d52a..d8cf656c 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -97,112 +97,119 @@ export default function GeneralForm() { return ( <> -
-

- General Settings -

-

- Configure the general settings for this resource -

-
+
+
+

+ General Settings +

+

+ Configure the general settings for this resource +

+
-
- - ( - - Name - - - - - This is the display name of the resource. - - - - )} - /> - ( - - Site - - - - - - - - - - - - No site found. - - - {sites.map((site) => ( - { - form.setValue( - "siteId", + + + ( + + Name + + + + + This is the display name of the + resource. + + + + )} + /> + ( + + Site + + + + + + + + + + + + No site found. + + + {sites.map((site) => ( + - - {site.name} - - ))} - - - - - - - This is the site that will be used in the - dashboard. - - - - )} - /> - - - + } + onSelect={() => { + form.setValue( + "siteId", + site.siteId + ); + }} + > + + {site.name} + + ))} + + + + + + + This is the site that will be used in + the dashboard. + + + + )} + /> + + + +
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index b08b3cb9..18a9f445 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -36,8 +36,12 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/{resourceId}/general`, }, { - title: "Targets", - href: `/{orgId}/settings/resources/{resourceId}/targets`, + title: "Connectivity", + href: `/{orgId}/settings/resources/{resourceId}/connectivity`, + }, + { + title: "Authentication", + href: `/{orgId}/settings/resources/{resourceId}/authentication`, }, ]; @@ -66,7 +70,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { {children} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx index 1751d779..8eb27e4e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx @@ -5,6 +5,6 @@ export default async function ResourcePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/resources/${params.resourceId}/general` + `/${params.orgId}/settings/resources/${params.resourceId}/connectivity` ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx deleted file mode 100644 index ed763bf5..00000000 --- a/src/app/[orgId]/settings/resources/[resourceId]/targets/page.tsx +++ /dev/null @@ -1,275 +0,0 @@ -"use client"; - -import { useEffect, useState, use } from "react"; -import { PlusCircle, Trash2, Server, Globe, Cpu } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import api from "@app/api"; -import { AxiosResponse } from "axios"; -import { ListTargetsResponse } from "@server/routers/target/listTargets"; - -const isValidIPAddress = (ip: string) => { - const ipv4Regex = - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - return ipv4Regex.test(ip); -}; - -export default function ReverseProxyTargets(props: { - params: Promise<{ resourceId: number }>; -}) { - const params = use(props.params); - const [targets, setTargets] = useState([]); - const [nextId, setNextId] = useState(1); - const [ipError, setIpError] = useState(""); - - useEffect(() => { - if (typeof window !== "undefined") { - const fetchSites = async () => { - const res = await api.get>( - `/resource/${params.resourceId}/targets` - ); - setTargets(res.data.data.targets); - }; - fetchSites(); - } - }, []); - - const [newTarget, setNewTarget] = useState({ - resourceId: params.resourceId, - ip: "", - method: "http", - port: 80, - protocol: "TCP", - }); - - const addTarget = () => { - if (!isValidIPAddress(newTarget.ip)) { - setIpError("Invalid IP address format"); - return; - } - setIpError(""); - - api.put(`/resource/${params.resourceId}/target`, { - ...newTarget, - resourceId: undefined, - }) - .catch((err) => { - console.error(err); - }) - .then((res) => { - // console.log(res) - setTargets([ - ...targets, - { ...newTarget, targetId: nextId, enabled: true }, - ]); - setNextId(nextId + 1); - setNewTarget({ - resourceId: params.resourceId, - ip: "", - method: "GET", - port: 80, - protocol: "http", - }); - }); - }; - - const removeTarget = (targetId: number) => { - api.delete(`/target/${targetId}`) - .catch((err) => { - console.error(err); - }) - .then((res) => { - setTargets( - targets.filter((target) => target.targetId !== targetId) - ); - }); - }; - - const toggleTarget = (targetId: number) => { - setTargets( - targets.map((target) => - target.targetId === targetId - ? { ...target, enabled: !target.enabled } - : target - ) - ); - api.post(`/target/${targetId}`, { - enabled: !targets.find((target) => target.targetId === targetId) - ?.enabled, - }).catch((err) => { - console.error(err); - }); - }; - - return ( -
-
-

Targets

-

- Setup the targets for the reverse proxy -

-
- -
{ - e.preventDefault(); - addTarget(); - }} - className="space-y-4" - > -
-
- - { - setNewTarget({ - ...newTarget, - ip: e.target.value, - }); - setIpError(""); - }} - required - /> - {ipError && ( -

{ipError}

- )} -
-
- - -
-
- - - setNewTarget({ - ...newTarget, - port: parseInt(e.target.value), - }) - } - required - /> -
-
- - -
-
- -
- -
- {targets.map((target, i) => ( - - - - - Target {target.targetId} - -
- - toggleTarget(target.targetId) - } - /> - -
-
- -
-
- - - {target.ip}:{target.port} - -
-
- - - {target.resourceId} - -
-
- - {target.method} - - - {target.protocol?.toUpperCase()} - -
-
-
-
- ))} -
-
- ); -} diff --git a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx index 7e431f20..e262fef5 100644 --- a/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/components/ResourcesTable.tsx @@ -9,7 +9,7 @@ import { DropdownMenuTrigger, } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import api from "@app/api"; @@ -95,34 +95,56 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return ( - - - + + + + + View settings + + + + + + + + - - - - - View settings - - - - - - - +
+ ); }, }, diff --git a/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx index 10a864c6..098194c2 100644 --- a/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/components/CreateSiteForm.tsx @@ -16,7 +16,7 @@ import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { set, z } from "zod"; +import { z } from "zod"; import { Credenza, CredenzaBody, @@ -31,10 +31,15 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { useParams, useRouter } from "next/navigation"; import { PickSiteDefaultsResponse } from "@server/routers/site"; import { generateKeypair } from "../[niceId]/components/wireguardConfig"; -import { cn } from "@app/lib/utils"; -import { ChevronDownIcon } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import { Checkbox } from "@app/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@app/components/ui/select"; const method = [ { label: "Wireguard", value: "wg" }, @@ -213,28 +218,26 @@ sh get-docker.sh`; render={({ field }) => ( Method -
- - + + + + + WireGuard - - + Newt - - - - -
+ + + + This is how you will connect your site to Fossorial. diff --git a/src/app/[orgId]/settings/sites/components/SitesTable.tsx b/src/app/[orgId]/settings/sites/components/SitesTable.tsx index 8be7fcb7..44e92441 100644 --- a/src/app/[orgId]/settings/sites/components/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/components/SitesTable.tsx @@ -9,7 +9,7 @@ import { DropdownMenuTrigger, } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import api from "@app/api"; @@ -104,34 +104,47 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { const siteRow = row.original; return ( - - - - - - - - View settings - - - - - - - +
+ + + + + + + + View settings + + + + + + + + +
); }, }, @@ -188,7 +201,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { setIsCreateModalOpen(true); }} /> - + {/* */} ); } diff --git a/src/app/globals.css b/src/app/globals.css index db3635e1..05b6167e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -38,7 +38,7 @@ --chart-4: 23.33 8.82% 60%; --chart-5: 24 8.98% 67.25%; - --radius: 0.75rem; + --radius: 0.35rem; } .dark { --background: 0 0% 11.76%; @@ -75,7 +75,7 @@ --chart-4: 23.33 23.68% 14.9%; --chart-5: 24 23.81% 12.35%; - --radius: 0.75rem; + --radius: 0.35rem; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9ce33521..0567f402 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; -import { Inter } from "next/font/google"; +import { Fira_Sans, Inter } from "next/font/google"; import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; diff --git a/src/components/SettingsSectionTitle.tsx b/src/components/SettingsSectionTitle.tsx new file mode 100644 index 00000000..733c8501 --- /dev/null +++ b/src/components/SettingsSectionTitle.tsx @@ -0,0 +1,16 @@ +type SettingsSectionTitleProps = { + title: string | React.ReactNode; + description: string | React.ReactNode; +}; + +export default function SettingsSectionTitle({ + title, + description, +}: SettingsSectionTitleProps) { + return ( +
+

{title}

+

{description}

+
+ ); +} diff --git a/src/components/sidebar-nav.tsx b/src/components/sidebar-nav.tsx index df08cd57..945a849d 100644 --- a/src/components/sidebar-nav.tsx +++ b/src/components/sidebar-nav.tsx @@ -59,9 +59,9 @@ export function SidebarNav({