add site targets, client resources, and auto login

This commit is contained in:
miloschwartz
2025-08-14 18:24:21 -07:00
parent 67ba225003
commit 5c04b1e14a
80 changed files with 5651 additions and 2385 deletions

View File

@@ -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
}}
/>
);
}

View File

@@ -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;

View File

@@ -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();
}}
/>
</>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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>
</>
);

View File

@@ -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>
)
)}

View File

@@ -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>

View File

@@ -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") && (
<>

View File

@@ -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

View File

@@ -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"

View File

@@ -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>
)}

View 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>
);
}

View File

@@ -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 ? (

View File

@@ -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>

View 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

View 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>
);
}

View File

@@ -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

View File

@@ -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"
)}
>

View File

@@ -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);

View File

@@ -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:

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -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

View File

@@ -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}

View File

@@ -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}

View File

@@ -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: (

View File

@@ -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
View 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
};
}
}

View File

@@ -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>