This commit is contained in:
Owen
2025-10-04 18:36:44 -07:00
parent 3123f858bb
commit c2c907852d
320 changed files with 35785 additions and 2984 deletions

View File

@@ -0,0 +1,55 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"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[];
createRemoteExitNode?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function ExitNodesDataTable<TData, TValue>({
columns,
data,
createRemoteExitNode,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title={t('remoteExitNodes')}
searchPlaceholder={t('searchRemoteExitNodes')}
searchColumn="name"
onAdd={createRemoteExitNode}
addButtonText={t('remoteExitNodeAdd')}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
defaultSort={{
id: "name",
desc: false
}}
/>
);
}

View File

@@ -0,0 +1,319 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExitNodesDataTable } from "./ExitNodesDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } 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 { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge";
export type RemoteExitNodeRow = {
id: string;
exitNodeId: number | null;
name: string;
address: string;
endpoint: string;
orgId: string;
type: string | null;
online: boolean;
dateCreated: string;
version?: string;
};
type ExitNodesTableProps = {
remoteExitNodes: RemoteExitNodeRow[];
orgId: string;
};
export default function ExitNodesTable({
remoteExitNodes,
orgId
}: ExitNodesTableProps) {
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<RemoteExitNodeRow | null>(
null
);
const [rows, setRows] = useState<RemoteExitNodeRow[]>(remoteExitNodes);
const [isRefreshing, setIsRefreshing] = useState(false);
const api = createApiClient(useEnvContext());
const t = useTranslations();
useEffect(() => {
setRows(remoteExitNodes);
}, [remoteExitNodes]);
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteRemoteExitNode = (remoteExitNodeId: string) => {
api.delete(`/org/${orgId}/remote-exit-node/${remoteExitNodeId}`)
.catch((e) => {
console.error(t("remoteExitNodeErrorDelete"), e);
toast({
variant: "destructive",
title: t("remoteExitNodeErrorDelete"),
description: formatAxiosError(
e,
t("remoteExitNodeErrorDelete")
)
});
})
.then(() => {
setIsDeleteModalOpen(false);
const newRows = rows.filter(
(row) => row.id !== remoteExitNodeId
);
setRows(newRows);
});
};
const columns: ColumnDef<RemoteExitNodeRow>[] = [
{
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: "online",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>
);
}
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("connectionType")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<Badge variant="secondary">
{originalRow.type === "remoteExitNode"
? "Remote Exit Node"
: originalRow.type}
</Badge>
);
}
},
{
accessorKey: "address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "endpoint",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Endpoint
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "version",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Version
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return originalRow.version || "-";
}
},
{
id: "actions",
cell: ({ row }) => {
const nodeRow = 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">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedNode(nodeRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
}
];
return (
<>
{selectedNode && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedNode(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("remoteExitNodeQuestionRemove", {
selectedNode:
selectedNode?.name || selectedNode?.id
})}
</p>
<p>{t("remoteExitNodeMessageRemove")}</p>
<p>{t("remoteExitNodeMessageConfirm")}</p>
</div>
}
buttonText={t("remoteExitNodeConfirmDelete")}
onConfirm={async () =>
deleteRemoteExitNode(selectedNode!.id)
}
string={selectedNode.name}
title={t("remoteExitNodeDelete")}
/>
)}
<ExitNodesDataTable
columns={columns}
data={rows}
createRemoteExitNode={() =>
router.push(`/${orgId}/settings/remote-exit-nodes/create`)
}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View File

@@ -0,0 +1,16 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export default function GeneralPage() {
return <></>;
}

View File

@@ -0,0 +1,59 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { internal } from "@app/lib/api";
import { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import RemoteExitNodeProvider from "@app/providers/PrivateRemoteExitNodeProvider";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ remoteExitNodeId: string; orgId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
let remoteExitNode = null;
try {
const res = await internal.get<
AxiosResponse<GetRemoteExitNodeResponse>
>(
`/org/${params.orgId}/remote-exit-node/${params.remoteExitNodeId}`,
await authCookieHeader()
);
remoteExitNode = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/remote-exit-nodes`);
}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={`Remote Exit Node ${remoteExitNode?.name || "Unknown"}`}
description="Manage your remote exit node settings and configuration"
/>
<RemoteExitNodeProvider remoteExitNode={remoteExitNode}>
<div className="space-y-6">{children}</div>
</RemoteExitNodeProvider>
</>
);
}

View File

@@ -0,0 +1,23 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { redirect } from "next/navigation";
export default async function RemoteExitNodePage(props: {
params: Promise<{ orgId: string; remoteExitNodeId: string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/general`
);
}

View File

@@ -0,0 +1,379 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { z } from "zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
QuickStartRemoteExitNodeResponse,
PickRemoteExitNodeDefaultsResponse
} from "@server/routers/private/remoteExitNode";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { StrategySelect } from "@app/components/StrategySelect";
export default function CreateRemoteExitNodePage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations();
const [isLoading, setIsLoading] = useState(false);
const [defaults, setDefaults] =
useState<PickRemoteExitNodeDefaultsResponse | null>(null);
const [createdNode, setCreatedNode] =
useState<QuickStartRemoteExitNodeResponse | null>(null);
const [strategy, setStrategy] = useState<"adopt" | "generate">("adopt");
const createRemoteExitNodeFormSchema = z
.object({
remoteExitNodeId: z.string().optional(),
secret: z.string().optional()
})
.refine(
(data) => {
if (strategy === "adopt") {
return data.remoteExitNodeId && data.secret;
}
return true;
},
{
message: t("remoteExitNodeCreate.validation.adoptRequired"),
path: ["remoteExitNodeId"]
}
);
type CreateRemoteExitNodeFormValues = z.infer<
typeof createRemoteExitNodeFormSchema
>;
const form = useForm<CreateRemoteExitNodeFormValues>({
resolver: zodResolver(createRemoteExitNodeFormSchema),
defaultValues: {}
});
// Check for query parameters and prefill form
useEffect(() => {
const remoteExitNodeId = searchParams.get("remoteExitNodeId");
const remoteExitNodeSecret = searchParams.get("remoteExitNodeSecret");
if (remoteExitNodeId && remoteExitNodeSecret) {
setStrategy("adopt");
form.setValue("remoteExitNodeId", remoteExitNodeId);
form.setValue("secret", remoteExitNodeSecret);
}
}, []);
useEffect(() => {
const loadDefaults = async () => {
try {
const response = await api.get<
AxiosResponse<PickRemoteExitNodeDefaultsResponse>
>(`/org/${orgId}/pick-remote-exit-node-defaults`);
setDefaults(response.data.data);
} catch (error) {
toast({
title: t("error"),
description: t(
"remoteExitNodeCreate.errors.loadDefaultsFailed"
),
variant: "destructive"
});
}
};
// Only load defaults when strategy is "generate"
if (strategy === "generate") {
loadDefaults();
}
}, [strategy]);
const onSubmit = async (data: CreateRemoteExitNodeFormValues) => {
if (strategy === "generate" && !defaults) {
toast({
title: t("error"),
description: t("remoteExitNodeCreate.errors.defaultsNotLoaded"),
variant: "destructive"
});
return;
}
if (strategy === "adopt" && (!data.remoteExitNodeId || !data.secret)) {
toast({
title: t("error"),
description: t("remoteExitNodeCreate.validation.adoptRequired"),
variant: "destructive"
});
return;
}
setIsLoading(true);
try {
const response = await api.put<
AxiosResponse<QuickStartRemoteExitNodeResponse>
>(`/org/${orgId}/remote-exit-node`, {
remoteExitNodeId:
strategy === "generate"
? defaults!.remoteExitNodeId
: data.remoteExitNodeId!,
secret:
strategy === "generate" ? defaults!.secret : data.secret!
});
setCreatedNode(response.data.data);
router.push(`/${orgId}/settings/remote-exit-nodes`);
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(
error,
t("remoteExitNodeCreate.errors.createFailed")
),
variant: "destructive"
});
} finally {
setIsLoading(false);
}
};
return (
<>
<div className="flex justify-between">
<HeaderTitle
title={t("remoteExitNodeCreate.title")}
description={t("remoteExitNodeCreate.description")}
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/remote-exit-nodes`);
}}
>
{t("remoteExitNodeCreate.viewAllButton")}
</Button>
</div>
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("remoteExitNodeCreate.strategy.title")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("remoteExitNodeCreate.strategy.description")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={[
{
id: "adopt",
title: t(
"remoteExitNodeCreate.strategy.adopt.title"
),
description: t(
"remoteExitNodeCreate.strategy.adopt.description"
)
},
{
id: "generate",
title: t(
"remoteExitNodeCreate.strategy.generate.title"
),
description: t(
"remoteExitNodeCreate.strategy.generate.description"
)
}
]}
defaultValue={strategy}
onChange={(value) => {
setStrategy(value);
// Clear adopt fields when switching to generate
if (value === "generate") {
form.setValue("remoteExitNodeId", "");
form.setValue("secret", "");
}
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
{strategy === "adopt" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("remoteExitNodeCreate.adopt.title")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"remoteExitNodeCreate.adopt.description"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<div className="space-y-4">
<FormField
control={form.control}
name="remoteExitNodeId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"remoteExitNodeCreate.adopt.nodeIdLabel"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"remoteExitNodeCreate.adopt.nodeIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"remoteExitNodeCreate.adopt.secretLabel"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"remoteExitNodeCreate.adopt.secretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{strategy === "generate" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("remoteExitNodeCreate.generate.title")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"remoteExitNodeCreate.generate.description"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<CopyTextBox
text={`managed:
id: "${defaults?.remoteExitNodeId}"
secret: "${defaults?.secret}"`}
/>
<Alert variant="neutral" className="mt-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t(
"remoteExitNodeCreate.generate.saveCredentialsTitle"
)}
</AlertTitle>
<AlertDescription>
{t(
"remoteExitNodeCreate.generate.saveCredentialsDescription"
)}
</AlertDescription>
</Alert>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/remote-exit-nodes`);
}}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={isLoading}
disabled={isLoading}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{strategy === "adopt"
? t("remoteExitNodeCreate.adopt.submitButton")
: t("remoteExitNodeCreate.generate.submitButton")}
</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,72 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListRemoteExitNodesResponse } from "@server/routers/private/remoteExitNode";
import { AxiosResponse } from "axios";
import ExitNodesTable, { RemoteExitNodeRow } from "./ExitNodesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
type RemoteExitNodesPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function RemoteExitNodesPage(
props: RemoteExitNodesPageProps
) {
const params = await props.params;
let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = [];
try {
const res = await internal.get<
AxiosResponse<ListRemoteExitNodesResponse>
>(`/org/${params.orgId}/remote-exit-nodes`, await authCookieHeader());
remoteExitNodes = res.data.data.remoteExitNodes;
} catch (e) {}
const t = await getTranslations();
const remoteExitNodeRows: RemoteExitNodeRow[] = remoteExitNodes.map(
(node) => {
return {
name: node.name,
id: node.remoteExitNodeId,
exitNodeId: node.exitNodeId,
address: node.address?.split("/")[0] || "-",
endpoint: node.endpoint || "-",
online: node.online,
type: node.type,
dateCreated: node.dateCreated,
version: node.version || undefined,
orgId: params.orgId
};
}
);
return (
<>
<SettingsSectionTitle
title={t("remoteExitNodeManageRemoteExitNodes")}
description={t("remoteExitNodeDescription")}
/>
<ExitNodesTable
remoteExitNodes={remoteExitNodeRows}
orgId={params.orgId}
/>
</>
);
}