move all components to components dir

This commit is contained in:
miloschwartz
2025-09-04 11:18:42 -07:00
parent 4292d3262e
commit df85f13aea
90 changed files with 2166 additions and 86 deletions

View File

@@ -1,954 +0,0 @@
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
MoreHorizontal,
ArrowUpRight,
ShieldOff,
ShieldCheck
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
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 { 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;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
};
export type InternalResourceRow = {
id: number;
name: string;
orgId: string;
siteName: string;
protocol: string;
proxyPort: number | null;
siteId: number;
siteNiceId: string;
destinationIp: string;
destinationPort: number;
};
type Site = ListSitesResponse["sites"][0];
type ResourcesTableProps = {
resources: ResourceRow[];
internalResources: InternalResourceRow[];
orgId: string;
defaultView?: "proxy" | "internal";
};
const STORAGE_KEYS = {
PAGE_SIZE: 'datatable-page-size',
getTablePageSize: (tableId?: string) =>
tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE
};
const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
if (typeof window === 'undefined') return defaultSize;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
const stored = localStorage.getItem(key);
if (stored) {
const parsed = parseInt(stored, 10);
if (parsed > 0 && parsed <= 1000) {
return parsed;
}
}
} catch (error) {
console.warn('Failed to read page size from localStorage:', error);
}
return defaultSize;
};
const setStoredPageSize = (pageSize: number, tableId?: string): void => {
if (typeof window === 'undefined') return;
try {
const key = STORAGE_KEYS.getTablePageSize(tableId);
localStorage.setItem(key, pageSize.toString());
} catch (error) {
console.warn('Failed to save page size to localStorage:', error);
}
};
export default function ResourcesTable({
resources,
internalResources,
orgId,
defaultView = "proxy"
}: ResourcesTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [proxyPageSize, setProxyPageSize] = useState<number>(() =>
getStoredPageSize('proxy-resources', 20)
);
const [internalPageSize, setInternalPageSize] = useState<number>(() =>
getStoredPageSize('internal-resources', 20)
);
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}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
};
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>>(
`resource/${resourceId}`,
{
enabled: val
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
});
}
const proxyColumns: ColumnDef<ResourceRow>[] = [
{
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: "protocol",
header: t("protocol"),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>;
}
},
{
accessorKey: "domain",
header: t("access"),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
/>
) : !resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
);
}
},
{
accessorKey: "authState",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
)}
</div>
);
}
},
{
accessorKey: "enabled",
header: t("enabled"),
cell: ({ row }) => (
<Switch
defaultChecked={
row.original.http
? !!row.original.domainId && row.original.enabled
: row.original.enabled
}
disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
/>
)
},
{
id: "actions",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end">
<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">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
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: proxyPageSize,
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: internalPageSize,
pageIndex: 0
}
},
state: {
sorting: internalSorting,
columnFilters: internalColumnFilters,
globalFilter: internalGlobalFilter
}
});
const handleProxyPageSizeChange = (newPageSize: number) => {
setProxyPageSize(newPageSize);
setStoredPageSize(newPageSize, 'proxy-resources');
};
const handleInternalPageSizeChange = (newPageSize: number) => {
setInternalPageSize(newPageSize);
setStoredPageSize(newPageSize, 'internal-resources');
};
return (
<>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div>
<p className="mb-2">
{t("resourceQuestionRemove", {
selectedResource:
selectedResource?.name ||
selectedResource?.id
})}
</p>
<p className="mb-2">{t("resourceMessageRemove")}</p>
<p>{t("resourceMessageConfirm")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t("resourceDelete")}
/>
)}
{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}
onPageSizeChange={handleProxyPageSizeChange}
/>
</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}
onPageSizeChange={handleInternalPageSizeChange}
/>
</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();
}}
/>
</>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { toUnicode } from "punycode";
interface DomainOption {
baseDomain: string;
domainId: string;
}
interface CustomDomainInputProps {
domainOptions: DomainOption[];
selectedDomainId?: string;
placeholder?: string;
value: string;
onChange?: (value: string, selectedDomainId: string) => void;
}
export default function CustomDomainInput({
domainOptions,
selectedDomainId,
placeholder = "Subdomain",
value: defaultValue,
onChange
}: CustomDomainInputProps) {
const [value, setValue] = React.useState(defaultValue);
const [selectedDomain, setSelectedDomain] = React.useState<DomainOption>();
React.useEffect(() => {
if (domainOptions.length) {
if (selectedDomainId) {
const selectedDomainOption = domainOptions.find(
(option) => option.domainId === selectedDomainId
);
setSelectedDomain(selectedDomainOption || domainOptions[0]);
} else {
setSelectedDomain(domainOptions[0]);
}
}
}, [domainOptions]);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedDomain) {
return;
}
const newValue = event.target.value;
setValue(newValue);
if (onChange) {
onChange(newValue, selectedDomain.domainId);
}
};
const handleDomainChange = (domainId: string) => {
const newSelectedDomain =
domainOptions.find((option) => option.domainId === domainId) ||
domainOptions[0];
setSelectedDomain(newSelectedDomain);
if (onChange) {
onChange(value, newSelectedDomain.domainId);
}
};
return (
<div className="w-full">
<div className="flex">
<Input
type="text"
placeholder={placeholder}
value={value}
onChange={handleInputChange}
className="w-1/2 mr-1 text-right"
/>
<Select
onValueChange={handleDomainChange}
value={selectedDomain?.domainId}
defaultValue={selectedDomain?.domainId}
>
<SelectTrigger className="w-1/2 pr-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{domainOptions.map((option) => (
<SelectItem
key={option.domainId}
value={option.domainId}
>
.{toUnicode(option.baseDomain)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -1,135 +0,0 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
import { useResourceContext } from "@app/hooks/useResourceContext";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { toUnicode } from 'punycode';
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
const { resource, authInfo } = useResourceContext();
const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
{resource.http ? (
<>
<InfoSection>
<InfoSectionTitle>
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>{t("notProtected")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={fullUrl}
isLink={true}
/>
</InfoSectionContent>
</InfoSection>
{/* {isEnabled && (
<InfoSection>
<InfoSectionTitle>Socket</InfoSectionTitle>
<InfoSectionContent>
{isAvailable ? (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
) : (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
)}
</InfoSectionContent>
</InfoSection>
)} */}
</>
) : (
<>
<InfoSection>
<InfoSectionTitle>
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
</span>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={resource.proxyPort!.toString()}
isLink={false}
/>
</InfoSectionContent>
</InfoSection>
{/* {build == "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("externalProxyEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enableProxy
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
)} */}
</>
)}
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enabled
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
);
}

View File

@@ -1,167 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
const setPasswordFormSchema = z.object({
password: z.string().min(4).max(100)
});
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
password: ""
};
type SetPasswordFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
resourceId: number;
onSetPassword?: () => void;
};
export default function SetResourcePasswordForm({
open,
setOpen,
resourceId,
onSetPassword
}: SetPasswordFormProps) {
const api = createApiClient(useEnvContext());
const t = useTranslations();
const [loading, setLoading] = useState(false);
const form = useForm<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema),
defaultValues
});
useEffect(() => {
if (!open) {
return;
}
form.reset();
}, [open]);
async function onSubmit(data: SetPasswordFormValues) {
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPasswordSetup'),
description: formatAxiosError(
e,
t('resourceErrorPasswordSetupDescription')
)
});
})
.then(() => {
toast({
title: t('resourcePasswordSetup'),
description: t('resourcePasswordSetupDescription')
});
if (onSetPassword) {
onSetPassword();
}
})
.finally(() => setLoading(false));
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('resourcePasswordSetupTitle')}</CredenzaTitle>
<CredenzaDescription>
{t('resourcePasswordSetupTitleDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="set-password-form"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
autoComplete="off"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
form="set-password-form"
loading={loading}
disabled={loading}
>
{t('resourcePasswordSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -1,202 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
const setPincodeFormSchema = z.object({
pincode: z.string().length(6)
});
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
const defaultValues: Partial<SetPincodeFormValues> = {
pincode: ""
};
type SetPincodeFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
resourceId: number;
onSetPincode?: () => void;
};
export default function SetResourcePincodeForm({
open,
setOpen,
resourceId,
onSetPincode
}: SetPincodeFormProps) {
const [loading, setLoading] = useState(false);
const api = createApiClient(useEnvContext());
const form = useForm<SetPincodeFormValues>({
resolver: zodResolver(setPincodeFormSchema),
defaultValues
});
const t = useTranslations();
useEffect(() => {
if (!open) {
return;
}
form.reset();
}, [open]);
async function onSubmit(data: SetPincodeFormValues) {
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPincodeSetup'),
description: formatAxiosError(
e,
t('resourceErrorPincodeSetupDescription')
)
});
})
.then(() => {
toast({
title: t('resourcePincodeSetup'),
description: t('resourcePincodeSetupDescription')
});
if (onSetPincode) {
onSetPincode();
}
})
.finally(() => setLoading(false));
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t('resourcePincodeSetupTitle')}</CredenzaTitle>
<CredenzaDescription>
{t('resourcePincodeSetupTitleDescription')}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="set-pincode-form"
>
<FormField
control={form.control}
name="pincode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resourcePincode')}</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
autoComplete="false"
maxLength={6}
{...field}
>
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
obscured
/>
<InputOTPSlot
index={1}
obscured
/>
<InputOTPSlot
index={2}
obscured
/>
<InputOTPSlot
index={3}
obscured
/>
<InputOTPSlot
index={4}
obscured
/>
<InputOTPSlot
index={5}
obscured
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t('close')}</Button>
</CredenzaClose>
<Button
type="submit"
form="set-pincode-form"
loading={loading}
disabled={loading}
>
{t('resourcePincodeSubmit')}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -27,8 +27,8 @@ import {
} from "@app/components/ui/form";
import { ListUsersResponse } from "@server/routers/user";
import { Binary, Key } from "lucide-react";
import SetResourcePasswordForm from "./SetResourcePasswordForm";
import SetResourcePincodeForm from "./SetResourcePincodeForm";
import SetResourcePasswordForm from "../../../../../../components/SetResourcePasswordForm";
import SetResourcePincodeForm from "../../../../../../components/SetResourcePincodeForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {

View File

@@ -54,7 +54,7 @@ import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "../../../domains/DomainsTable";
import { DomainRow } from "../../../../../../components/DomainsTable";
import { toASCII, toUnicode } from "punycode";
export default function GeneralForm() {
@@ -160,7 +160,7 @@ export default function GeneralForm() {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
baseDomain: toUnicode(domain.baseDomain),
}));
setBaseDomains(domains);
setFormKey((key) => key + 1);

View File

@@ -12,7 +12,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "./ResourceInfoBox";
import ResourceInfoBox from "../../../../../components/ResourceInfoBox";
import { GetSiteResponse } from "@server/routers/site";
import { getTranslations } from 'next-intl/server';

View File

@@ -90,7 +90,7 @@ import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from 'punycode';
import { DomainRow } from "../../domains/DomainsTable";
import { DomainRow } from "../../../../../components/DomainsTable";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),

View File

@@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import ResourcesTable, {
ResourceRow,
InternalResourceRow
} from "./ResourcesTable";
} from "../../../../components/ResourcesTable";
import { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource";
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";