mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-13 08:26:40 +00:00
add site targets, client resources, and auto login
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createResource?: () => void;
|
||||
}
|
||||
|
||||
export function ResourcesDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createResource
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title={t('resources')}
|
||||
searchPlaceholder={t('resourcesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createResource}
|
||||
addButtonText={t('resourceAdd')}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Server, Lock, Key, Users, X, ArrowRight } from "lucide-react"; // Replace with actual imports
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const ResourcesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
const key = "resources-splash-dismissed";
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key);
|
||||
if (dismissed === "true") {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full mx-auto overflow-hidden mb-8 hidden md:block relative">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<CardContent className="grid gap-6 p-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Server className="text-blue-500" />
|
||||
{t('resources')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('resourcesDescription')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-green-500 w-4 h-4" />
|
||||
{t('resourcesWireGuardConnect')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Key className="text-yellow-500 w-4 h-4" />
|
||||
{t('resourcesMultipleAuthenticationMethods')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Users className="text-purple-500 w-4 h-4" />
|
||||
{t('resourcesUsersRolesAccess')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourcesSplashCard;
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ResourcesDataTable } from "./ResourcesDataTable";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -10,18 +19,16 @@ import {
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Copy,
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Check,
|
||||
ArrowUpRight,
|
||||
ShieldOff,
|
||||
ShieldCheck
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
@@ -31,17 +38,37 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@app/components/ui/table";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from "@app/components/ui/tabs";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
domain: string;
|
||||
site: string;
|
||||
siteId: string;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
@@ -50,20 +77,147 @@ export type ResourceRow = {
|
||||
domainId?: string;
|
||||
};
|
||||
|
||||
type ResourcesTableProps = {
|
||||
resources: ResourceRow[];
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
siteId: number;
|
||||
siteNiceId: string;
|
||||
destinationIp: string;
|
||||
destinationPort: number;
|
||||
};
|
||||
|
||||
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type ResourcesTableProps = {
|
||||
resources: ResourceRow[];
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
defaultView?: "proxy" | "internal";
|
||||
};
|
||||
|
||||
export default function SitesTable({
|
||||
resources,
|
||||
internalResources,
|
||||
orgId,
|
||||
defaultView = "proxy"
|
||||
}: ResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRow | null>();
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
|
||||
const [proxySorting, setProxySorting] = useState<SortingState>([]);
|
||||
const [proxyColumnFilters, setProxyColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [proxyGlobalFilter, setProxyGlobalFilter] = useState<any>([]);
|
||||
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
|
||||
const [internalColumnFilters, setInternalColumnFilters] =
|
||||
useState<ColumnFiltersState>([]);
|
||||
const [internalGlobalFilter, setInternalGlobalFilter] = useState<any>([]);
|
||||
|
||||
const currentView = searchParams.get("view") || defaultView;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
`/org/${orgId}/sites`
|
||||
);
|
||||
setSites(res.data.data.sites);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sites:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
fetchSites();
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value === "internal") {
|
||||
params.set("view", "internal");
|
||||
} else {
|
||||
params.delete("view");
|
||||
}
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
};
|
||||
|
||||
const getSearchInput = () => {
|
||||
if (currentView === "internal") {
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={internalGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
internalTable.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={t("resourcesSearch")}
|
||||
value={proxyGlobalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
proxyTable.setGlobalFilter(String(e.target.value))
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getActionButton = () => {
|
||||
if (currentView === "internal") {
|
||||
return (
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/${orgId}/settings/resources/create`)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("resourceAdd")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
@@ -81,6 +235,26 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const deleteInternalResource = async (
|
||||
resourceId: number,
|
||||
siteId: number
|
||||
) => {
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/site/${siteId}/resource/${resourceId}`
|
||||
);
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (e) {
|
||||
console.error(t("resourceErrorDelete"), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorDelte"),
|
||||
description: formatAxiosError(e, t("v"))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function toggleResourceEnabled(val: boolean, resourceId: number) {
|
||||
const res = await api
|
||||
.post<AxiosResponse<UpdateResourceResponse>>(
|
||||
@@ -101,7 +275,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ResourceRow>[] = [
|
||||
const proxyColumns: ColumnDef<ResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
@@ -118,35 +292,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "site",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("site")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{resourceRow.site}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: t("protocol"),
|
||||
@@ -225,10 +370,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
<Switch
|
||||
defaultChecked={
|
||||
row.original.http
|
||||
? (!!row.original.domainId && row.original.enabled)
|
||||
? !!row.original.domainId && row.original.enabled
|
||||
: row.original.enabled
|
||||
}
|
||||
disabled={row.original.http ? !row.original.domainId : false}
|
||||
disabled={
|
||||
row.original.http ? !row.original.domainId : false
|
||||
}
|
||||
onCheckedChange={(val) =>
|
||||
toggleResourceEnabled(val, row.original.id)
|
||||
}
|
||||
@@ -289,6 +436,163 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
}
|
||||
];
|
||||
|
||||
const internalColumns: ColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
header: t("siteName"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{resourceRow.siteName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: t("protocol"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return <span>{resourceRow.protocol.toUpperCase()}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "proxyPort",
|
||||
header: t("proxyPort"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.proxyPort!.toString()}
|
||||
isLink={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destination",
|
||||
header: t("resourcesTableDestination"),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`;
|
||||
return <CopyToClipboard text={destination} isLink={false} />;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedInternalResource(
|
||||
resourceRow
|
||||
);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingResource(resourceRow);
|
||||
setIsEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const proxyTable = useReactTable({
|
||||
data: resources,
|
||||
columns: proxyColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setProxySorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setProxyColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setProxyGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting: proxySorting,
|
||||
columnFilters: proxyColumnFilters,
|
||||
globalFilter: proxyGlobalFilter
|
||||
}
|
||||
});
|
||||
|
||||
const internalTable = useReactTable({
|
||||
data: internalResources,
|
||||
columns: internalColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setInternalSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setInternalColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setInternalGlobalFilter,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting: internalSorting,
|
||||
columnFilters: internalColumnFilters,
|
||||
globalFilter: internalGlobalFilter
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedResource && (
|
||||
@@ -320,11 +624,271 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResourcesDataTable
|
||||
columns={columns}
|
||||
data={resources}
|
||||
createResource={() => {
|
||||
router.push(`/${orgId}/settings/resources/create`);
|
||||
{selectedInternalResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedInternalResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
{t("resourceQuestionRemove", {
|
||||
selectedResource:
|
||||
selectedInternalResource?.name ||
|
||||
selectedInternalResource?.id
|
||||
})}
|
||||
</p>
|
||||
|
||||
<p className="mb-2">{t("resourceMessageRemove")}</p>
|
||||
|
||||
<p>{t("resourceMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("resourceDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteInternalResource(
|
||||
selectedInternalResource!.id,
|
||||
selectedInternalResource!.siteId
|
||||
)
|
||||
}
|
||||
string={selectedInternalResource.name}
|
||||
title={t("resourceDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue={defaultView}
|
||||
className="w-full"
|
||||
onValueChange={handleTabChange}
|
||||
>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-0">
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
{getSearchInput()}
|
||||
|
||||
{env.flags.enableClients && (
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="proxy">
|
||||
{t("resourcesTableProxyResources")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="internal">
|
||||
{t("resourcesTableClientResources")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{getActionButton()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TabsContent value="proxy">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{proxyTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxyTable.getRowModel().rows
|
||||
?.length ? (
|
||||
proxyTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
proxyColumns.length
|
||||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoProxyResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={proxyTable} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="internal">
|
||||
<div className="mb-4">
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"resourcesTableTheseResourcesForUseWith"
|
||||
)}{" "}
|
||||
<Link
|
||||
href={`/${orgId}/settings/clients`}
|
||||
className="font-medium underline hover:opacity-80 inline-flex items-center"
|
||||
>
|
||||
{t("resourcesTableClients")}
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
</Link>{" "}
|
||||
{t(
|
||||
"resourcesTableAndOnlyAccessibleInternally"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{internalTable
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(
|
||||
(header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header
|
||||
.column
|
||||
.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{internalTable.getRowModel().rows
|
||||
?.length ? (
|
||||
internalTable
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
internalColumns.length
|
||||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"resourcesTableNoInternalResourcesFound"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={internalTable}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{editingResource && (
|
||||
<EditInternalResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
setEditingResource(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateInternalResourceDialog
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -10,35 +10,22 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
const { resource, authInfo, site } = useResourceContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { resource, authInfo } = useResourceContext();
|
||||
|
||||
const { isEnabled, isAvailable } = useDockerSocket(site!);
|
||||
const t = useTranslations();
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("resourceInfo")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={4}>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={3}>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
@@ -71,12 +58,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.siteName}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{/* {isEnabled && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Socket</InfoSectionTitle>
|
||||
@@ -117,7 +98,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{build == "oss" && (
|
||||
{/* {build == "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("externalProxyEnabled")}
|
||||
@@ -130,7 +111,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
|
||||
@@ -49,6 +49,15 @@ import { UserType } from "@server/types/UserTypes";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
@@ -110,6 +119,14 @@ export default function ResourceAuthenticationPage() {
|
||||
resource.emailWhitelistEnabled
|
||||
);
|
||||
|
||||
const [autoLoginEnabled, setAutoLoginEnabled] = useState(
|
||||
resource.skipToIdpId !== null && resource.skipToIdpId !== undefined
|
||||
);
|
||||
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
||||
resource.skipToIdpId || null
|
||||
);
|
||||
const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]);
|
||||
|
||||
const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false);
|
||||
const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false);
|
||||
|
||||
@@ -139,7 +156,8 @@ export default function ResourceAuthenticationPage() {
|
||||
resourceRolesResponse,
|
||||
usersResponse,
|
||||
resourceUsersResponse,
|
||||
whitelist
|
||||
whitelist,
|
||||
idpsResponse
|
||||
] = await Promise.all([
|
||||
api.get<AxiosResponse<ListRolesResponse>>(
|
||||
`/org/${org?.org.orgId}/roles`
|
||||
@@ -155,7 +173,12 @@ export default function ResourceAuthenticationPage() {
|
||||
),
|
||||
api.get<AxiosResponse<GetResourceWhitelistResponse>>(
|
||||
`/resource/${resource.resourceId}/whitelist`
|
||||
)
|
||||
),
|
||||
api.get<
|
||||
AxiosResponse<{
|
||||
idps: { idpId: number; name: string }[];
|
||||
}>
|
||||
>("/idp")
|
||||
]);
|
||||
|
||||
setAllRoles(
|
||||
@@ -200,6 +223,21 @@ export default function ResourceAuthenticationPage() {
|
||||
}))
|
||||
);
|
||||
|
||||
setAllIdps(
|
||||
idpsResponse.data.data.idps.map((idp) => ({
|
||||
id: idp.idpId,
|
||||
text: idp.name
|
||||
}))
|
||||
);
|
||||
|
||||
if (
|
||||
autoLoginEnabled &&
|
||||
!selectedIdpId &&
|
||||
idpsResponse.data.data.idps.length > 0
|
||||
) {
|
||||
setSelectedIdpId(idpsResponse.data.data.idps[0].idpId);
|
||||
}
|
||||
|
||||
setPageLoading(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -260,6 +298,16 @@ export default function ResourceAuthenticationPage() {
|
||||
try {
|
||||
setLoadingSaveUsersRoles(true);
|
||||
|
||||
// Validate that an IDP is selected if auto login is enabled
|
||||
if (autoLoginEnabled && !selectedIdpId) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("error"),
|
||||
description: t("selectIdpRequired")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs = [
|
||||
api.post(`/resource/${resource.resourceId}/roles`, {
|
||||
roleIds: data.roles.map((i) => parseInt(i.id))
|
||||
@@ -268,14 +316,16 @@ export default function ResourceAuthenticationPage() {
|
||||
userIds: data.users.map((i) => i.id)
|
||||
}),
|
||||
api.post(`/resource/${resource.resourceId}`, {
|
||||
sso: ssoEnabled
|
||||
sso: ssoEnabled,
|
||||
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
|
||||
})
|
||||
];
|
||||
|
||||
await Promise.all(jobs);
|
||||
|
||||
updateResource({
|
||||
sso: ssoEnabled
|
||||
sso: ssoEnabled,
|
||||
skipToIdpId: autoLoginEnabled ? selectedIdpId : null
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
@@ -542,6 +592,89 @@ export default function ResourceAuthenticationPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{ssoEnabled && allIdps.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<div className="space-y-2 mb-3">
|
||||
<CheckboxWithLabel
|
||||
label={t(
|
||||
"autoLoginExternalIdp"
|
||||
)}
|
||||
checked={autoLoginEnabled}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
setAutoLoginEnabled(
|
||||
checked as boolean
|
||||
);
|
||||
if (
|
||||
checked &&
|
||||
allIdps.length > 0
|
||||
) {
|
||||
setSelectedIdpId(
|
||||
allIdps[0].id
|
||||
);
|
||||
} else {
|
||||
setSelectedIdpId(
|
||||
null
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"autoLoginExternalIdpDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{autoLoginEnabled && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("selectIdp")}
|
||||
</label>
|
||||
<Select
|
||||
onValueChange={(
|
||||
value
|
||||
) =>
|
||||
setSelectedIdpId(
|
||||
parseInt(value)
|
||||
)
|
||||
}
|
||||
value={
|
||||
selectedIdpId
|
||||
? selectedIdpId.toString()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectIdpPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allIdps.map(
|
||||
(idp) => (
|
||||
<SelectItem
|
||||
key={
|
||||
idp.id
|
||||
}
|
||||
value={idp.id.toString()}
|
||||
>
|
||||
{
|
||||
idp.text
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -14,19 +14,6 @@ import {
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -45,25 +32,11 @@ import {
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import CustomDomainInput from "../CustomDomainInput";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { subdomainSchema, tlsNameSchema } from "@server/lib/schemas";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
UpdateResourceResponse,
|
||||
updateResourceRule
|
||||
} from "@server/routers/resource";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
@@ -81,12 +54,6 @@ import DomainPicker from "@app/components/DomainPicker";
|
||||
import { Globe } from "lucide-react";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
});
|
||||
|
||||
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||
|
||||
export default function GeneralForm() {
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
const params = useParams();
|
||||
@@ -127,7 +94,7 @@ export default function GeneralForm() {
|
||||
name: z.string().min(1).max(255),
|
||||
domainId: z.string().optional(),
|
||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||
enableProxy: z.boolean().optional()
|
||||
// enableProxy: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -156,18 +123,11 @@ export default function GeneralForm() {
|
||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||
domainId: resource.domainId || undefined,
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
enableProxy: resource.enableProxy || false
|
||||
// enableProxy: resource.enableProxy || false
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
const transferForm = useForm<TransferFormValues>({
|
||||
resolver: zodResolver(TransferFormSchema),
|
||||
defaultValues: {
|
||||
siteId: resource.siteId ? Number(resource.siteId) : undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSites = async () => {
|
||||
const res = await api.get<AxiosResponse<ListSitesResponse>>(
|
||||
@@ -221,9 +181,9 @@ export default function GeneralForm() {
|
||||
subdomain: data.subdomain,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
})
|
||||
// ...(!resource.http && {
|
||||
// enableProxy: data.enableProxy
|
||||
// })
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -251,9 +211,9 @@ export default function GeneralForm() {
|
||||
subdomain: data.subdomain,
|
||||
fullDomain: resource.fullDomain,
|
||||
proxyPort: data.proxyPort,
|
||||
...(!resource.http && {
|
||||
enableProxy: data.enableProxy
|
||||
}),
|
||||
// ...(!resource.http && {
|
||||
// enableProxy: data.enableProxy
|
||||
// })
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
@@ -261,40 +221,6 @@ export default function GeneralForm() {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
|
||||
async function onTransfer(data: TransferFormValues) {
|
||||
setTransferLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post(`resource/${resource?.resourceId}/transfer`, {
|
||||
siteId: data.siteId
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorTransfer"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourceErrorTransferDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: t("resourceTransferred"),
|
||||
description: t("resourceTransferredDescription")
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
updateResource({
|
||||
siteName:
|
||||
sites.find((site) => site.siteId === data.siteId)?.name ||
|
||||
""
|
||||
});
|
||||
}
|
||||
setTransferLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
!loadingPage && (
|
||||
<>
|
||||
@@ -410,7 +336,7 @@ export default function GeneralForm() {
|
||||
)}
|
||||
/>
|
||||
|
||||
{build == "oss" && (
|
||||
{/* {build == "oss" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableProxy"
|
||||
@@ -444,13 +370,15 @@ export default function GeneralForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
|
||||
{resource.http && (
|
||||
<div className="space-y-2">
|
||||
<Label>Domain</Label>
|
||||
<Label>
|
||||
{t("resourceDomain")}
|
||||
</Label>
|
||||
<div className="border p-2 rounded-md flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Globe size="14" />
|
||||
@@ -466,7 +394,9 @@ export default function GeneralForm() {
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit Domain
|
||||
{t(
|
||||
"resourceEditDomain"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -490,140 +420,6 @@ export default function GeneralForm() {
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceTransfer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceTransferDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...transferForm}>
|
||||
<form
|
||||
onSubmit={transferForm.handleSubmit(
|
||||
onTransfer
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="transfer-form"
|
||||
>
|
||||
<FormField
|
||||
control={transferForm.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("siteDestination")}
|
||||
</FormLabel>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? sites.find(
|
||||
(
|
||||
site
|
||||
) =>
|
||||
site.siteId ===
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"searchSites"
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{t(
|
||||
"sitesNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
(
|
||||
site
|
||||
) => (
|
||||
<CommandItem
|
||||
value={`${site.name}:${site.siteId}`}
|
||||
key={
|
||||
site.siteId
|
||||
}
|
||||
onSelect={() => {
|
||||
transferForm.setValue(
|
||||
"siteId",
|
||||
site.siteId
|
||||
);
|
||||
setOpen(
|
||||
false
|
||||
);
|
||||
}}
|
||||
>
|
||||
{
|
||||
site.name
|
||||
}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
site.siteId ===
|
||||
field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={transferLoading}
|
||||
disabled={transferLoading}
|
||||
form="transfer-form"
|
||||
>
|
||||
{t("resourceTransferSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
|
||||
<Credenza
|
||||
|
||||
@@ -29,7 +29,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
|
||||
let authInfo = null;
|
||||
let resource = null;
|
||||
let site = null;
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
|
||||
`/resource/${params.resourceId}`,
|
||||
@@ -44,19 +43,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
|
||||
// Fetch site info
|
||||
if (resource.siteId) {
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetSiteResponse>>(
|
||||
`/site/${resource.siteId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
site = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/resources`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourceAuthInfoResponse>
|
||||
@@ -119,7 +105,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ResourceProvider
|
||||
site={site}
|
||||
resource={resource}
|
||||
authInfo={authInfo}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,40 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import ResourcesTable, { ResourceRow } from "./ResourcesTable";
|
||||
import ResourcesTable, {
|
||||
ResourceRow,
|
||||
InternalResourceRow
|
||||
} from "./ResourcesTable";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListResourcesResponse } from "@server/routers/resource";
|
||||
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import ResourcesSplashCard from "./ResourcesSplashCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslations();
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
// Default to 'proxy' view, or use the query param if provided
|
||||
let defaultView: "proxy" | "internal" = "proxy";
|
||||
if (env.flags.enableClients) {
|
||||
defaultView = searchParams.view === "internal" ? "internal" : "proxy";
|
||||
}
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
@@ -30,6 +44,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
resources = res.data.data.resources;
|
||||
} catch (e) {}
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
||||
siteResources = res.data.data.siteResources;
|
||||
} catch (e) {}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const getOrg = cache(async () =>
|
||||
@@ -54,8 +76,6 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
site: resource.siteName || t('none'),
|
||||
siteId: resource.siteId || t('unknown'),
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
@@ -72,17 +92,39 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||
(siteResource) => {
|
||||
return {
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
protocol: siteResource.protocol,
|
||||
proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
destinationIp: siteResource.destinationIp,
|
||||
destinationPort: siteResource.destinationPort,
|
||||
siteNiceId: siteResource.siteNiceId
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <ResourcesSplashCard /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title={t('resourceTitle')}
|
||||
description={t('resourceDescription')}
|
||||
title={t("resourceTitle")}
|
||||
description={t("resourceDescription")}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
|
||||
<ResourcesTable
|
||||
resources={resourceRows}
|
||||
internalResources={internalResourceRows}
|
||||
orgId={params.orgId}
|
||||
defaultView={
|
||||
env.flags.enableClients ? defaultView : "proxy"
|
||||
}
|
||||
/>
|
||||
</OrgProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -98,7 +98,6 @@ export default function CreateShareLinkForm({
|
||||
resourceId: number;
|
||||
name: string;
|
||||
resourceUrl: string;
|
||||
siteName: string | null;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
@@ -160,8 +159,7 @@ export default function CreateShareLinkForm({
|
||||
.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`,
|
||||
siteName: r.siteName
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -236,8 +234,7 @@ export default function CreateShareLinkForm({
|
||||
resourceName: values.resourceName,
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt,
|
||||
siteName: resource?.siteName || null
|
||||
expiresAt: token.expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
@@ -246,7 +243,7 @@ export default function CreateShareLinkForm({
|
||||
|
||||
function getSelectedResourceName(id: number) {
|
||||
const resource = resources.find((r) => r.resourceId === id);
|
||||
return `${resource?.name} ${resource?.siteName ? `(${resource.siteName})` : ""}`;
|
||||
return `${resource?.name}`;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -346,7 +343,7 @@ export default function CreateShareLinkForm({
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`}
|
||||
{`${r.name}`}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,6 @@ export type ShareLinkRow = {
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
siteName: string | null;
|
||||
};
|
||||
|
||||
type ShareLinksTableProps = {
|
||||
@@ -104,8 +103,7 @@ export default function ShareLinksTable({
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{r.resourceName}{" "}
|
||||
{r.siteName ? `(${r.siteName})` : ""}
|
||||
{r.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -33,9 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">{t("siteInfo")}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<AlertDescription>
|
||||
<InfoSections cols={env.flags.enableClients ? 3 : 2}>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
|
||||
@@ -38,12 +38,14 @@ import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
remoteSubnets: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
).optional()
|
||||
remoteSubnets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
@@ -55,7 +57,9 @@ export default function GeneralPage() {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(null);
|
||||
const [activeCidrTagIndex, setActiveCidrTagIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
@@ -66,10 +70,10 @@ export default function GeneralPage() {
|
||||
name: site?.name,
|
||||
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
|
||||
remoteSubnets: site?.remoteSubnets
|
||||
? site.remoteSubnets.split(',').map((subnet, index) => ({
|
||||
id: subnet.trim(),
|
||||
text: subnet.trim()
|
||||
}))
|
||||
? site.remoteSubnets.split(",").map((subnet, index) => ({
|
||||
id: subnet.trim(),
|
||||
text: subnet.trim()
|
||||
}))
|
||||
: []
|
||||
},
|
||||
mode: "onChange"
|
||||
@@ -82,7 +86,10 @@ export default function GeneralPage() {
|
||||
.post(`/site/${site?.siteId}`, {
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
remoteSubnets:
|
||||
data.remoteSubnets
|
||||
?.map((subnet) => subnet.text)
|
||||
.join(",") || ""
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
@@ -98,7 +105,8 @@ export default function GeneralPage() {
|
||||
updateSite({
|
||||
name: data.name,
|
||||
dockerSocketEnabled: data.dockerSocketEnabled,
|
||||
remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || ''
|
||||
remoteSubnets:
|
||||
data.remoteSubnets?.map((subnet) => subnet.text).join(",") || ""
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -145,42 +153,64 @@ export default function GeneralPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remoteSubnets"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("remoteSubnets")}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={activeCidrTagIndex}
|
||||
setActiveTagIndex={setActiveCidrTagIndex}
|
||||
placeholder={t("enterCidrRange")}
|
||||
size="sm"
|
||||
tags={form.getValues().remoteSubnets || []}
|
||||
setTags={(newSubnets) => {
|
||||
form.setValue(
|
||||
"remoteSubnets",
|
||||
newSubnets as Tag[]
|
||||
);
|
||||
}}
|
||||
validateTag={(tag) => {
|
||||
// Basic CIDR validation regex
|
||||
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||
return cidrRegex.test(tag);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("remoteSubnetsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{env.flags.enableClients &&
|
||||
site.type === "newt" ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="remoteSubnets"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("remoteSubnets")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
activeTagIndex={
|
||||
activeCidrTagIndex
|
||||
}
|
||||
setActiveTagIndex={
|
||||
setActiveCidrTagIndex
|
||||
}
|
||||
placeholder={t(
|
||||
"enterCidrRange"
|
||||
)}
|
||||
size="sm"
|
||||
tags={
|
||||
form.getValues()
|
||||
.remoteSubnets ||
|
||||
[]
|
||||
}
|
||||
setTags={(
|
||||
newSubnets
|
||||
) => {
|
||||
form.setValue(
|
||||
"remoteSubnets",
|
||||
newSubnets as Tag[]
|
||||
);
|
||||
}}
|
||||
validateTag={(tag) => {
|
||||
// Basic CIDR validation regex
|
||||
const cidrRegex =
|
||||
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||
return cidrRegex.test(
|
||||
tag
|
||||
);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"remoteSubnetsDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{site && site.type === "newt" && (
|
||||
<FormField
|
||||
|
||||
@@ -877,7 +877,7 @@ WantedBy=default.target`
|
||||
<p className="font-bold mb-3">
|
||||
{t("siteConfiguration")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<CheckboxWithLabel
|
||||
id="acceptClients"
|
||||
aria-describedby="acceptClients-desc"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -112,12 +113,11 @@ export default function InitialSetupPage() {
|
||||
<FormItem>
|
||||
<FormLabel>{t("setupToken")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("setupTokenPlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input {...field} autoComplete="off" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("setupTokenDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
100
src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx
Normal file
100
src/app/auth/resource/[resourceId]/AutoLoginHandler.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription
|
||||
} from "@app/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AutoLoginHandlerProps = {
|
||||
resourceId: number;
|
||||
skipToIdpId: number;
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export default function AutoLoginHandler({
|
||||
resourceId,
|
||||
skipToIdpId,
|
||||
redirectUrl
|
||||
}: AutoLoginHandlerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function initiateAutoLogin() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<GenerateOidcUrlResponse>
|
||||
>(`/auth/idp/${skipToIdpId}/oidc/generate-url`, {
|
||||
redirectUrl
|
||||
});
|
||||
|
||||
if (res.data.data.redirectUrl) {
|
||||
// Redirect to the IDP for authentication
|
||||
window.location.href = res.data.data.redirectUrl;
|
||||
} else {
|
||||
setError(t("autoLoginErrorNoRedirectUrl"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to generate OIDC URL:", e);
|
||||
setError(formatAxiosError(e, t("autoLoginErrorGeneratingUrl")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
initiateAutoLogin();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("autoLoginTitle")}</CardTitle>
|
||||
<CardDescription>{t("autoLoginDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t("autoLoginProcessing")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{t("autoLoginRedirecting")}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>{t("autoLoginError")}</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import AccessToken from "./AccessToken";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import AutoLoginHandler from "./AutoLoginHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -30,11 +31,13 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
const env = pullEnv();
|
||||
|
||||
const authHeader = await authCookieHeader();
|
||||
|
||||
let authInfo: GetResourceAuthInfoResponse | undefined;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<GetResourceAuthInfoResponse>
|
||||
>(`/resource/${params.resourceId}/auth`, await authCookieHeader());
|
||||
>(`/resource/${params.resourceId}/auth`, authHeader);
|
||||
|
||||
if (res && res.status === 200) {
|
||||
authInfo = res.data.data;
|
||||
@@ -62,10 +65,9 @@ export default async function ResourceAuthPage(props: {
|
||||
const redirectPort = new URL(searchParams.redirect).port;
|
||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
||||
|
||||
|
||||
if (serverResourceHost === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
} else if ( serverResourceHostWithPort === redirectHost ) {
|
||||
} else if (serverResourceHostWithPort === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -144,6 +146,19 @@ export default async function ResourceAuthPage(props: {
|
||||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
if (authInfo.skipToIdpId && authInfo.skipToIdpId !== null) {
|
||||
const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId);
|
||||
if (idp) {
|
||||
return (
|
||||
<AutoLoginHandler
|
||||
resourceId={authInfo.resourceId}
|
||||
skipToIdpId={authInfo.skipToIdpId}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{userIsUnauthorized && isSSOOnly ? (
|
||||
|
||||
@@ -43,35 +43,30 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Search, RefreshCw, Filter, Columns } from "lucide-react";
|
||||
import { GetSiteResponse, Container } from "@server/routers/site";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { Container } from "@server/routers/site";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// Type definitions based on the JSON structure
|
||||
import { FaDocker } from "react-icons/fa";
|
||||
|
||||
interface ContainerSelectorProps {
|
||||
site: GetSiteResponse;
|
||||
site: { siteId: number; name: string; type: string };
|
||||
containers: Container[];
|
||||
isAvailable: boolean;
|
||||
onContainerSelect?: (hostname: string, port?: number) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||
site,
|
||||
onContainerSelect
|
||||
containers,
|
||||
isAvailable,
|
||||
onContainerSelect,
|
||||
onRefresh
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const { isAvailable, containers, fetchContainers } = useDockerSocket(site);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("DockerSocket isAvailable:", isAvailable);
|
||||
if (isAvailable) {
|
||||
fetchContainers();
|
||||
}
|
||||
}, [isAvailable]);
|
||||
|
||||
if (!site || !isAvailable) {
|
||||
if (!site || !isAvailable || site.type !== "newt") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -84,13 +79,14 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
<Button
|
||||
type="button"
|
||||
className="text-sm text-primary hover:underline cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("viewDockerContainers")}
|
||||
>
|
||||
{t("viewDockerContainers")}
|
||||
</a>
|
||||
<FaDocker size={15} />
|
||||
</Button>
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
|
||||
<CredenzaHeader>
|
||||
@@ -106,7 +102,7 @@ export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||
<DockerContainersTable
|
||||
containers={containers}
|
||||
onContainerSelect={handleContainerSelect}
|
||||
onRefresh={() => fetchContainers()}
|
||||
onRefresh={onRefresh || (() => {})}
|
||||
/>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
@@ -263,7 +259,9 @@ const DockerContainersTable: FC<{
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs hover:bg-muted"
|
||||
>
|
||||
{t("containerLabelsCount", { count: labelEntries.length })}
|
||||
{t("containerLabelsCount", {
|
||||
count: labelEntries.length
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start">
|
||||
@@ -279,7 +277,10 @@ const DockerContainersTable: FC<{
|
||||
{key}
|
||||
</div>
|
||||
<div className="font-mono text-muted-foreground pl-2 break-all">
|
||||
{value || t("containerLabelEmpty")}
|
||||
{value ||
|
||||
t(
|
||||
"containerLabelEmpty"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -316,7 +317,9 @@ const DockerContainersTable: FC<{
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="link" size="sm">
|
||||
{t("containerPortsMore", { count: ports.length - 2 })}
|
||||
{t("containerPortsMore", {
|
||||
count: ports.length - 2
|
||||
})}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -356,7 +359,9 @@ const DockerContainersTable: FC<{
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onContainerSelect(row.original, ports[0])}
|
||||
onClick={() =>
|
||||
onContainerSelect(row.original, ports[0])
|
||||
}
|
||||
disabled={row.original.state !== "running"}
|
||||
>
|
||||
{t("select")}
|
||||
@@ -415,9 +420,7 @@ const DockerContainersTable: FC<{
|
||||
hideStoppedContainers) &&
|
||||
containers.length > 0 ? (
|
||||
<>
|
||||
<p>
|
||||
{t("noContainersMatchingFilters")}
|
||||
</p>
|
||||
<p>{t("noContainersMatchingFilters")}</p>
|
||||
<div className="space-x-2">
|
||||
{hideContainersWithoutPorts && (
|
||||
<Button
|
||||
@@ -446,9 +449,7 @@ const DockerContainersTable: FC<{
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{t("noContainersFound")}
|
||||
</p>
|
||||
<p>{t("noContainersFound")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,7 +464,9 @@ const DockerContainersTable: FC<{
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("searchContainersPlaceholder", { count: initialFilters.length })}
|
||||
placeholder={t("searchContainersPlaceholder", {
|
||||
count: initialFilters.length
|
||||
})}
|
||||
value={searchInput}
|
||||
onChange={(event) =>
|
||||
setSearchInput(event.target.value)
|
||||
@@ -473,7 +476,10 @@ const DockerContainersTable: FC<{
|
||||
{searchInput &&
|
||||
table.getFilteredRowModel().rows.length > 0 && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
{t("searchResultsCount", { count: table.getFilteredRowModel().rows.length })}
|
||||
{t("searchResultsCount", {
|
||||
count: table.getFilteredRowModel().rows
|
||||
.length
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -644,7 +650,9 @@ const DockerContainersTable: FC<{
|
||||
{t("searching")}
|
||||
</div>
|
||||
) : (
|
||||
t("noContainersFoundMatching", { filter: globalFilter })
|
||||
t("noContainersFoundMatching", {
|
||||
filter: globalFilter
|
||||
})
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
422
src/components/CreateInternalResourceDialog.tsx
Normal file
422
src/components/CreateInternalResourceDialog.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
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 {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type CreateInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
sites: Site[];
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function CreateInternalResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
sites,
|
||||
onSuccess
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("createInternalResourceDialogNameRequired"))
|
||||
.max(255, t("createInternalResourceDialogNameMaxLength")),
|
||||
siteId: z.number().int().positive(t("createInternalResourceDialogPleaseSelectSite")),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, t("createInternalResourceDialogProxyPortMin"))
|
||||
.max(65535, t("createInternalResourceDialogProxyPortMax")),
|
||||
destinationIp: z.string().ip(t("createInternalResourceDialogInvalidIPAddressFormat")),
|
||||
destinationPort: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, t("createInternalResourceDialogDestinationPortMin"))
|
||||
.max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
siteId: availableSites[0]?.siteId || 0,
|
||||
protocol: "tcp",
|
||||
proxyPort: undefined,
|
||||
destinationIp: "",
|
||||
destinationPort: undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && availableSites.length > 0) {
|
||||
form.reset({
|
||||
name: "",
|
||||
siteId: availableSites[0].siteId,
|
||||
protocol: "tcp",
|
||||
proxyPort: undefined,
|
||||
destinationIp: "",
|
||||
destinationPort: undefined
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await api.put(`/org/${orgId}/site/${data.siteId}/resource`, {
|
||||
name: data.name,
|
||||
protocol: data.protocol,
|
||||
proxyPort: data.proxyPort,
|
||||
destinationIp: data.destinationIp,
|
||||
destinationPort: data.destinationPort,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error creating internal resource:", error);
|
||||
toast({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (availableSites.length === 0) {
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-md">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogNoSitesAvailable")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogNoSitesAvailableDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaFooter>
|
||||
<Button onClick={() => setOpen(false)}>{t("createInternalResourceDialogClose")}</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-6"
|
||||
id="create-internal-resource-form"
|
||||
>
|
||||
{/* Resource Properties Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("createInternalResourceDialogResourceProperties")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("createInternalResourceDialogName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? availableSites.find(
|
||||
(site) => site.siteId === field.value
|
||||
)?.name
|
||||
: t("createInternalResourceDialogSelectSite")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableSites.map((site) => (
|
||||
<CommandItem
|
||||
key={site.siteId}
|
||||
value={site.name}
|
||||
onSelect={() => {
|
||||
field.onChange(site.siteId);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === site.siteId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{site.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogProtocol")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">
|
||||
{t("createInternalResourceDialogTcp")}
|
||||
</SelectItem>
|
||||
<SelectItem value="udp">
|
||||
{t("createInternalResourceDialogUdp")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === "" ? undefined : parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("createInternalResourceDialogSitePortDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Configuration Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("createInternalResourceDialogTargetConfiguration")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationIp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogDestinationIP")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("createInternalResourceDialogDestinationIPDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("createInternalResourceDialogDestinationPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === "" ? undefined : parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("createInternalResourceDialogDestinationPortDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
276
src/components/EditInternalResourceDialog.tsx
Normal file
276
src/components/EditInternalResourceDialog.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
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 {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
|
||||
type InternalResourceData = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
siteId: number;
|
||||
destinationIp?: string;
|
||||
destinationPort?: number;
|
||||
};
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
resource: InternalResourceData;
|
||||
orgId: string;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export default function EditInternalResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
resource,
|
||||
orgId,
|
||||
onSuccess
|
||||
}: EditInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")),
|
||||
destinationIp: z.string().ip(t("editInternalResourceDialogInvalidIPAddressFormat")),
|
||||
destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax"))
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: resource.name,
|
||||
protocol: resource.protocol as "tcp" | "udp",
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
destinationIp: resource.destinationIp || "",
|
||||
destinationPort: resource.destinationPort || undefined
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
name: resource.name,
|
||||
protocol: resource.protocol as "tcp" | "udp",
|
||||
proxyPort: resource.proxyPort || undefined,
|
||||
destinationIp: resource.destinationIp || "",
|
||||
destinationPort: resource.destinationPort || undefined
|
||||
});
|
||||
}
|
||||
}, [open, resource, form]);
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Update the site resource
|
||||
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
|
||||
name: data.name,
|
||||
protocol: data.protocol,
|
||||
proxyPort: data.proxyPort,
|
||||
destinationIp: data.destinationIp,
|
||||
destinationPort: data.destinationPort
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("editInternalResourceDialogSuccess"),
|
||||
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
|
||||
variant: "default"
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error updating internal resource:", error);
|
||||
toast({
|
||||
title: t("editInternalResourceDialogError"),
|
||||
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
|
||||
{/* Resource Properties Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxyPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Configuration Form */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationIp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogDestinationIP")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("editInternalResourceDialogDestinationPort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function LayoutSidebar({
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pt-1">
|
||||
<div className="px-2 pt-1">
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div className="pb-4">
|
||||
<Link
|
||||
|
||||
@@ -54,7 +54,7 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"shadow-xs",
|
||||
"shadow-2xs",
|
||||
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -150,7 +150,7 @@ export function SidebarNav({
|
||||
{section.heading}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 mt-1 pl-2">
|
||||
{section.items.map((item) => {
|
||||
const hydratedHref = hydrateHref(item.href);
|
||||
const isActive = pathname.startsWith(hydratedHref);
|
||||
|
||||
@@ -9,7 +9,7 @@ const alertVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card border text-foreground",
|
||||
neutral: "bg-card border text-foreground",
|
||||
neutral: "bg-card bg-muted border text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
success:
|
||||
|
||||
@@ -30,7 +30,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type TabFilter = {
|
||||
id: string;
|
||||
label: string;
|
||||
filterFn: (row: any) => boolean;
|
||||
};
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -46,6 +54,8 @@ type DataTableProps<TData, TValue> = {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
tabs?: TabFilter[];
|
||||
defaultTab?: string;
|
||||
};
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -58,17 +68,36 @@ export function DataTable<TData, TValue>({
|
||||
isRefreshing,
|
||||
searchPlaceholder = "Search...",
|
||||
searchColumn = "name",
|
||||
defaultSort
|
||||
defaultSort,
|
||||
tabs,
|
||||
defaultTab
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState<any>([]);
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTab || tabs?.[0]?.id || ""
|
||||
);
|
||||
const t = useTranslations();
|
||||
|
||||
// Apply tab filter to data
|
||||
const filteredData = useMemo(() => {
|
||||
if (!tabs || activeTab === "") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
|
||||
if (!activeTabFilter) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter(activeTabFilter.filterFn);
|
||||
}, [data, tabs, activeTab]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: filteredData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
@@ -90,20 +119,49 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
// Reset to first page when changing tabs
|
||||
table.setPageIndex(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
||||
<div className="flex items-center w-full sm:max-w-sm sm:mr-2 relative">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
table.setGlobalFilter(String(e.target.value))
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
table.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
>
|
||||
{tab.label} (
|
||||
{data.filter(tab.filterFn).length})
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:justify-end">
|
||||
{onRefresh && (
|
||||
|
||||
@@ -55,7 +55,7 @@ function InputOTPSlot({
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-2xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
type={showPassword ? "text" : "password"}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
@@ -43,7 +43,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base inset-shadow-2xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm shadow-2xs",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
|
||||
@@ -36,7 +36,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-2xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground cursor-pointer",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import { GetSiteResponse } from "@server/routers/site";
|
||||
import { createContext } from "react";
|
||||
|
||||
interface ResourceContextType {
|
||||
resource: GetResourceResponse;
|
||||
site: GetSiteResponse | null;
|
||||
authInfo: GetResourceAuthInfoResponse;
|
||||
updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
|
||||
updateAuthInfo: (
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEnvContext } from "./useEnvContext";
|
||||
import {
|
||||
Container,
|
||||
GetDockerStatusResponse,
|
||||
ListContainersResponse,
|
||||
TriggerFetchResponse
|
||||
} from "@server/routers/site";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { toast } from "./useToast";
|
||||
import { Site } from "@server/db";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export function useDockerSocket(site: Site) {
|
||||
console.log(`useDockerSocket initialized for site ID: ${site.siteId}`);
|
||||
|
||||
const [dockerSocket, setDockerSocket] = useState<GetDockerStatusResponse>();
|
||||
const [containers, setContainers] = useState<Container[]>([]);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { dockerSocketEnabled: rawIsEnabled = true, type: siteType } = site || {};
|
||||
const isEnabled = rawIsEnabled && siteType === "newt";
|
||||
const { isAvailable = false, socketPath } = dockerSocket || {};
|
||||
|
||||
const checkDockerSocket = useCallback(async () => {
|
||||
if (!isEnabled) {
|
||||
console.warn("Docker socket is not enabled for this site.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.post(`/site/${site.siteId}/docker/check`);
|
||||
console.log("Docker socket check response:", res);
|
||||
} catch (error) {
|
||||
console.error("Failed to check Docker socket:", error);
|
||||
}
|
||||
}, [api, site.siteId, isEnabled]);
|
||||
|
||||
const getDockerSocketStatus = useCallback(async () => {
|
||||
if (!isEnabled) {
|
||||
console.warn("Docker socket is not enabled for this site.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<GetDockerStatusResponse>>(
|
||||
`/site/${site.siteId}/docker/status`
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
setDockerSocket(res.data.data);
|
||||
} else {
|
||||
console.error("Failed to get Docker status:", res);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to get Docker status",
|
||||
description:
|
||||
"An error occurred while fetching Docker status."
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get Docker status:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to get Docker status",
|
||||
description: "An error occurred while fetching Docker status."
|
||||
});
|
||||
}
|
||||
}, [api, site.siteId, isEnabled]);
|
||||
|
||||
const getContainers = useCallback(
|
||||
async (maxRetries: number = 3) => {
|
||||
if (!isEnabled || !isAvailable) {
|
||||
console.warn("Docker socket is not enabled or available.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchContainerList = async () => {
|
||||
if (!isEnabled || !isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
const res = await api.get<
|
||||
AxiosResponse<ListContainersResponse>
|
||||
>(`/site/${site.siteId}/docker/containers`);
|
||||
setContainers(res.data.data);
|
||||
return res.data.data;
|
||||
} catch (error: any) {
|
||||
attempt++;
|
||||
|
||||
// Check if the error is a 425 (Too Early) status
|
||||
if (error?.response?.status === 425) {
|
||||
if (attempt < maxRetries) {
|
||||
console.log(
|
||||
`Containers not ready yet (attempt ${attempt}/${maxRetries}). Retrying in 250ms...`
|
||||
);
|
||||
await sleep(250);
|
||||
continue;
|
||||
} else {
|
||||
console.warn(
|
||||
"Max retry attempts reached. Containers may still be loading."
|
||||
);
|
||||
// toast({
|
||||
// variant: "destructive",
|
||||
// title: "Containers not ready",
|
||||
// description:
|
||||
// "Containers are still loading. Please try again in a moment."
|
||||
// });
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Failed to fetch Docker containers:",
|
||||
error
|
||||
);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch containers",
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
"An error occurred while fetching containers"
|
||||
)
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await api.post<AxiosResponse<TriggerFetchResponse>>(
|
||||
`/site/${site.siteId}/docker/trigger`
|
||||
);
|
||||
// TODO: identify a way to poll the server for latest container list periodically?
|
||||
await fetchContainerList();
|
||||
return res.data.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger Docker containers:", error);
|
||||
}
|
||||
},
|
||||
[api, site.siteId, isEnabled, isAvailable]
|
||||
);
|
||||
|
||||
// 2. Docker socket status monitoring
|
||||
useEffect(() => {
|
||||
if (!isEnabled || isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkDockerSocket();
|
||||
getDockerSocketStatus();
|
||||
|
||||
}, [isEnabled, isAvailable, checkDockerSocket, getDockerSocketStatus]);
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isAvailable: isEnabled && isAvailable,
|
||||
socketPath,
|
||||
containers,
|
||||
check: checkDockerSocket,
|
||||
status: getDockerSocketStatus,
|
||||
fetchContainers: getContainers
|
||||
};
|
||||
}
|
||||
136
src/lib/docker.ts
Normal file
136
src/lib/docker.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
Container,
|
||||
GetDockerStatusResponse,
|
||||
ListContainersResponse,
|
||||
TriggerFetchResponse
|
||||
} from "@server/routers/site";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export interface DockerState {
|
||||
isEnabled: boolean;
|
||||
isAvailable: boolean;
|
||||
socketPath?: string;
|
||||
containers: Container[];
|
||||
}
|
||||
|
||||
export class DockerManager {
|
||||
private api: any;
|
||||
private siteId: number;
|
||||
|
||||
constructor(api: any, siteId: number) {
|
||||
this.api = api;
|
||||
this.siteId = siteId;
|
||||
}
|
||||
|
||||
async checkDockerSocket(): Promise<void> {
|
||||
try {
|
||||
const res = await this.api.post(`/site/${this.siteId}/docker/check`);
|
||||
console.log("Docker socket check response:", res);
|
||||
} catch (error) {
|
||||
console.error("Failed to check Docker socket:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getDockerSocketStatus(): Promise<GetDockerStatusResponse | null> {
|
||||
try {
|
||||
const res = await this.api.get(
|
||||
`/site/${this.siteId}/docker/status`
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
return res.data.data as GetDockerStatusResponse;
|
||||
} else {
|
||||
console.error("Failed to get Docker status:", res);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get Docker status:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchContainers(maxRetries: number = 3): Promise<Container[]> {
|
||||
const fetchContainerList = async (): Promise<Container[]> => {
|
||||
let attempt = 0;
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
const res = await this.api.get(
|
||||
`/site/${this.siteId}/docker/containers`
|
||||
);
|
||||
return res.data.data as Container[];
|
||||
} catch (error: any) {
|
||||
attempt++;
|
||||
|
||||
// Check if the error is a 425 (Too Early) status
|
||||
if (error?.response?.status === 425) {
|
||||
if (attempt < maxRetries) {
|
||||
console.log(
|
||||
`Containers not ready yet (attempt ${attempt}/${maxRetries}). Retrying in 250ms...`
|
||||
);
|
||||
await sleep(250);
|
||||
continue;
|
||||
} else {
|
||||
console.warn(
|
||||
"Max retry attempts reached. Containers may still be loading."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Failed to fetch Docker containers:",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
try {
|
||||
await this.api.post(
|
||||
`/site/${this.siteId}/docker/trigger`
|
||||
);
|
||||
return await fetchContainerList();
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger Docker containers:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async initializeDocker(): Promise<DockerState> {
|
||||
console.log(`Initializing Docker for site ID: ${this.siteId}`);
|
||||
|
||||
// For now, assume Docker is enabled for newt sites
|
||||
const isEnabled = true;
|
||||
|
||||
if (!isEnabled) {
|
||||
return {
|
||||
isEnabled: false,
|
||||
isAvailable: false,
|
||||
containers: []
|
||||
};
|
||||
}
|
||||
|
||||
// Check and get Docker socket status
|
||||
await this.checkDockerSocket();
|
||||
const dockerStatus = await this.getDockerSocketStatus();
|
||||
|
||||
const isAvailable = dockerStatus?.isAvailable || false;
|
||||
let containers: Container[] = [];
|
||||
|
||||
if (isAvailable) {
|
||||
containers = await this.fetchContainers();
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isAvailable,
|
||||
socketPath: dockerStatus?.socketPath,
|
||||
containers
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,17 @@
|
||||
import ResourceContext from "@app/contexts/resourceContext";
|
||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import { GetSiteResponse } from "@server/routers/site";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ResourceProviderProps {
|
||||
children: React.ReactNode;
|
||||
resource: GetResourceResponse;
|
||||
site: GetSiteResponse | null;
|
||||
authInfo: GetResourceAuthInfoResponse;
|
||||
}
|
||||
|
||||
export function ResourceProvider({
|
||||
children,
|
||||
site,
|
||||
resource: serverResource,
|
||||
authInfo: serverAuthInfo
|
||||
}: ResourceProviderProps) {
|
||||
@@ -66,7 +63,7 @@ export function ResourceProvider({
|
||||
|
||||
return (
|
||||
<ResourceContext.Provider
|
||||
value={{ resource, updateResource, site, authInfo, updateAuthInfo }}
|
||||
value={{ resource, updateResource, authInfo, updateAuthInfo }}
|
||||
>
|
||||
{children}
|
||||
</ResourceContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user