mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-13 05:40:38 +01:00
add new create site workflow
This commit is contained in:
parent
cdf904a2bc
commit
edba818615
16 changed files with 3416 additions and 283 deletions
|
@ -41,7 +41,7 @@ gerbil:
|
|||
rate_limits:
|
||||
global:
|
||||
window_minutes: 1
|
||||
max_requests: 100
|
||||
max_requests: 500
|
||||
{{if .EnableEmail}}
|
||||
email:
|
||||
smtp_host: "{{.EmailSMTPHost}}"
|
||||
|
|
2670
package-lock.json
generated
2670
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -57,6 +57,7 @@
|
|||
"glob": "11.0.0",
|
||||
"helmet": "8.0.0",
|
||||
"http-errors": "2.0.0",
|
||||
"i": "^0.3.7",
|
||||
"input-otp": "1.4.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"lucide-react": "0.469.0",
|
||||
|
@ -66,12 +67,14 @@
|
|||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.16",
|
||||
"npm": "^11.2.0",
|
||||
"oslo": "1.2.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "7.6.3",
|
||||
"tailwind-merge": "2.6.0",
|
||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
|||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.0.2";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
|
|
@ -110,10 +110,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10">
|
||||
<div className="border-b">
|
||||
<div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
|
||||
<div className="container mx-auto flex flex-col content-between">
|
||||
<div className="my-4">
|
||||
<div className="my-4 px-3">
|
||||
<UserProvider user={user}>
|
||||
<Header orgId={params.orgId} orgs={orgs} />
|
||||
</UserProvider>
|
||||
|
@ -121,13 +120,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
<TopbarNav items={topNavItems} orgId={params.orgId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
|
||||
<div className="container mx-auto sm:px-0 px-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -331,7 +331,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
columns={columns}
|
||||
data={rows}
|
||||
addSite={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
router.push(`/${orgId}/settings/sites/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
861
src/app/[orgId]/settings/sites/create/page.tsx
Normal file
861
src/app/[orgId]/settings/sites/create/page.tsx
Normal file
|
@ -0,0 +1,861 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
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 { Terminal, InfoIcon } from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { FaWindows, FaApple, FaFreebsd, FaDocker } from "react-icons/fa";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
CreateSiteBody,
|
||||
CreateSiteResponse,
|
||||
PickSiteDefaultsResponse
|
||||
} from "@server/routers/site";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const createSiteFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(30, {
|
||||
message: "Name must not be longer than 30 characters."
|
||||
}),
|
||||
method: z.string(),
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.method !== "local") {
|
||||
return data.copied;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Please confirm that you have copied the config.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
type Commands = {
|
||||
mac: Record<string, string[]>;
|
||||
linux: Record<string, string[]>;
|
||||
windows: Record<string, string[]>;
|
||||
docker: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [tunnelTypes, setTunnelTypes] = useState<any>([
|
||||
{
|
||||
id: "newt",
|
||||
title: "Newt Tunnel (Recommended)",
|
||||
description:
|
||||
"Easiest way to create an entrypoint into your network. No extra setup.",
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "wireguard",
|
||||
title: "Basic WireGuard",
|
||||
description:
|
||||
"Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
title: "Local",
|
||||
description: "Local resources only. No tunneling."
|
||||
}
|
||||
]);
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
|
||||
const [platform, setPlatform] = useState("linux");
|
||||
const [architecture, setArchitecture] = useState("amd64");
|
||||
const [commands, setCommands] = useState<Commands | null>(null);
|
||||
|
||||
const [newtId, setNewtId] = useState("");
|
||||
const [newtSecret, setNewtSecret] = useState("");
|
||||
const [newtEndpoint, setNewtEndpoint] = useState("");
|
||||
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [wgConfig, setWgConfig] = useState("");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
|
||||
const [siteDefaults, setSiteDefaults] =
|
||||
useState<PickSiteDefaultsResponse | null>(null);
|
||||
|
||||
const hydrateWireGuardConfig = (
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
subnet: string,
|
||||
address: string,
|
||||
endpoint: string,
|
||||
listenPort: string
|
||||
) => {
|
||||
const wgConfig = `[Interface]
|
||||
Address = ${subnet}
|
||||
ListenPort = 51820
|
||||
PrivateKey = ${privateKey}
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${publicKey}
|
||||
AllowedIPs = ${address.split("/")[0]}/32
|
||||
Endpoint = ${endpoint}:${listenPort}
|
||||
PersistentKeepalive = 5`;
|
||||
setWgConfig(wgConfig);
|
||||
};
|
||||
|
||||
const hydrateCommands = (
|
||||
id: string,
|
||||
secret: string,
|
||||
endpoint: string,
|
||||
version: string
|
||||
) => {
|
||||
const commands = {
|
||||
mac: {
|
||||
"Apple Silicon (arm64)": [
|
||||
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
"Intel x64 (amd64)": [
|
||||
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
linux: {
|
||||
amd64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32v6: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32v6" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
riscv64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_riscv64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
freebsd: {
|
||||
amd64: [
|
||||
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
`curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`,
|
||||
`newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
docker: {
|
||||
"Docker Compose": [
|
||||
`services:
|
||||
newt:
|
||||
image: fosrl/newt
|
||||
container_name: newt
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PANGOLIN_ENDPOINT=${endpoint}
|
||||
- NEWT_ID=${id}
|
||||
- NEWT_SECRET=${secret}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -it fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
}
|
||||
};
|
||||
setCommands(commands);
|
||||
};
|
||||
|
||||
const getArchitectures = () => {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||
case "mac":
|
||||
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
case "docker":
|
||||
return ["Docker Compose", "Docker Run"];
|
||||
case "freebsd":
|
||||
return ["amd64", "arm64"];
|
||||
default:
|
||||
return ["x64"];
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformName = (platformName: string) => {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return "Windows";
|
||||
case "mac":
|
||||
return "macOS";
|
||||
case "docker":
|
||||
return "Docker";
|
||||
case "freebsd":
|
||||
return "FreeBSD";
|
||||
default:
|
||||
return "Linux";
|
||||
}
|
||||
};
|
||||
|
||||
const getCommand = () => {
|
||||
const placeholder = ["Unknown command"];
|
||||
if (!commands) {
|
||||
return placeholder;
|
||||
}
|
||||
let platformCommands = commands[platform as keyof Commands];
|
||||
|
||||
if (!platformCommands) {
|
||||
// get first key
|
||||
const firstPlatform = Object.keys(commands)[0];
|
||||
platformCommands = commands[firstPlatform as keyof Commands];
|
||||
|
||||
setPlatform(firstPlatform);
|
||||
}
|
||||
|
||||
let architectureCommands = platformCommands[architecture];
|
||||
if (!architectureCommands) {
|
||||
// get first key
|
||||
const firstArchitecture = Object.keys(platformCommands)[0];
|
||||
architectureCommands = platformCommands[firstArchitecture];
|
||||
|
||||
setArchitecture(firstArchitecture);
|
||||
}
|
||||
|
||||
return architectureCommands || placeholder;
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platformName: string) => {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||
case "mac":
|
||||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createSiteFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
copied: false,
|
||||
method: "newt"
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: CreateSiteFormValues) {
|
||||
setCreateLoading(true);
|
||||
|
||||
let payload: CreateSiteBody = {
|
||||
name: data.name,
|
||||
type: data.method
|
||||
};
|
||||
|
||||
if (data.method == "wireguard") {
|
||||
if (!siteDefaults || !wgConfig) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Key pair or site defaults not found"
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
payload = {
|
||||
...payload,
|
||||
subnet: siteDefaults.subnet,
|
||||
exitNodeId: siteDefaults.exitNodeId,
|
||||
pubKey: publicKey
|
||||
};
|
||||
}
|
||||
if (data.method === "newt") {
|
||||
if (!siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Site defaults not found"
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
payload = {
|
||||
...payload,
|
||||
subnet: siteDefaults.subnet,
|
||||
exitNodeId: siteDefaults.exitNodeId,
|
||||
secret: siteDefaults.newtSecret,
|
||||
newtId: siteDefaults.newtId
|
||||
};
|
||||
}
|
||||
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<CreateSiteResponse>
|
||||
>(`/org/${orgId}/site/`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const data = res.data.data;
|
||||
|
||||
router.push(`/${orgId}/settings/sites/${data.niceId}`);
|
||||
}
|
||||
|
||||
setCreateLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoadingPage(true);
|
||||
|
||||
let newtVersion = "latest";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/fosrl/newt/releases/latest`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch release info: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
newtVersion = latestVersion;
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest release:", error);
|
||||
}
|
||||
|
||||
const generatedKeypair = generateKeypair();
|
||||
|
||||
const privateKey = generatedKeypair.privateKey;
|
||||
const publicKey = generatedKeypair.publicKey;
|
||||
|
||||
setPrivateKey(privateKey);
|
||||
setPublicKey(publicKey);
|
||||
|
||||
await api
|
||||
.get(`/org/${orgId}/pick-site-defaults`)
|
||||
.catch((e) => {
|
||||
// update the default value of the form to be local method
|
||||
form.setValue("method", "local");
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.status === 200) {
|
||||
const data = res.data.data;
|
||||
|
||||
setSiteDefaults(data);
|
||||
|
||||
const newtId = data.newtId;
|
||||
const newtSecret = data.newtSecret;
|
||||
const newtEndpoint = data.endpoint;
|
||||
|
||||
setNewtId(newtId);
|
||||
setNewtSecret(newtSecret);
|
||||
setNewtEndpoint(newtEndpoint);
|
||||
|
||||
hydrateCommands(
|
||||
newtId,
|
||||
newtSecret,
|
||||
env.app.dashboardUrl,
|
||||
newtVersion
|
||||
);
|
||||
|
||||
hydrateWireGuardConfig(
|
||||
privateKey,
|
||||
data.publicKey,
|
||||
data.subnet,
|
||||
data.address,
|
||||
data.endpoint,
|
||||
data.listenPort
|
||||
);
|
||||
|
||||
setTunnelTypes((prev: any) => {
|
||||
return prev.map((item: any) => {
|
||||
return {
|
||||
...item,
|
||||
disabled: false
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setLoadingPage(false);
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex-row">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<Link href="../">Sites</Link>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Create Site</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Site"
|
||||
description="Follow the steps below to create and connect a new site"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
See All Sites
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!loadingPage && (
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Site Information
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the the
|
||||
display name for the
|
||||
site.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Tunnel Type
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to connect to your
|
||||
site
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={tunnelTypes}
|
||||
defaultValue={
|
||||
form.getValues("method") as string
|
||||
}
|
||||
onChange={(value) =>
|
||||
form.setValue("method", value)
|
||||
}
|
||||
cols={3}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{form.watch("method") === "newt" && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Newt Credentials
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
This is how Newt will authenticate
|
||||
with the server
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Endpoint
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
env.app.dashboardUrl
|
||||
}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt ID
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={newtId}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Secret Key
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={newtSecret}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
<Alert variant="default" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see
|
||||
this once. Make sure to copy it
|
||||
to a secure place.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="copied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
defaultChecked={
|
||||
form.getValues(
|
||||
"copied"
|
||||
) as boolean
|
||||
}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) => {
|
||||
form.setValue(
|
||||
"copied",
|
||||
e as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have
|
||||
copied the
|
||||
config
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Install Newt
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Get Newt running on your system
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
Operating System
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
"linux",
|
||||
"docker",
|
||||
"mac",
|
||||
"windows",
|
||||
"freebsd"
|
||||
].map((os) => (
|
||||
<Button
|
||||
key={os}
|
||||
variant={
|
||||
platform === os
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`}
|
||||
onClick={() => {
|
||||
setPlatform(os);
|
||||
}}
|
||||
>
|
||||
{getPlatformIcon(os)}
|
||||
{getPlatformName(os)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{platform === "docker"
|
||||
? "Method"
|
||||
: "Architecture"}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{getArchitectures().map(
|
||||
(arch) => (
|
||||
<Button
|
||||
key={arch}
|
||||
variant={
|
||||
architecture ===
|
||||
arch
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`}
|
||||
onClick={() =>
|
||||
setArchitecture(
|
||||
arch
|
||||
)
|
||||
}
|
||||
>
|
||||
{arch}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
Commands
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CopyTextBox
|
||||
text={getCommand().join(
|
||||
"\n"
|
||||
)}
|
||||
outline={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.watch("method") === "wireguard" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
WireGuard Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Use the following configuration to
|
||||
connect to your network
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<CopyTextBox text={wgConfig} />
|
||||
|
||||
<Alert variant="default">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="copied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
defaultChecked={
|
||||
form.getValues(
|
||||
"copied"
|
||||
) as boolean
|
||||
}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) => {
|
||||
form.setValue(
|
||||
"copied",
|
||||
e as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the config
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Create Site
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -6,7 +6,7 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
|
|||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { BookOpenText } from "lucide-react";
|
||||
import { BookOpenText, ExternalLink } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
@ -46,9 +46,16 @@ export default async function RootLayout({
|
|||
<span>Pangolin</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="whitespace-nowrap">
|
||||
Built by Fossorial
|
||||
</div>
|
||||
<a
|
||||
href="https://fossorial.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Built by Fossorial"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Fossorial</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
|
|
|
@ -96,7 +96,8 @@ export default function StepperForm() {
|
|||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
setCurrentStep("site");
|
||||
// setCurrentStep("site");
|
||||
router.push(`/${values.orgId}/settings/sites/create`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
@ -290,42 +291,6 @@ export default function StepperForm() {
|
|||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{currentStep === "site" && (
|
||||
<div>
|
||||
<CreateSiteForm
|
||||
setLoading={(val) => setLoading(val)}
|
||||
setChecked={(val) => setIsChecked(val)}
|
||||
orgId={orgForm.getValues().orgId}
|
||||
onCreate={() => {
|
||||
router.push(
|
||||
`/${orgForm.getValues().orgId}/settings/resources`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/${orgForm.getValues().orgId}/settings/sites`
|
||||
);
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-site-form"
|
||||
loading={loading}
|
||||
disabled={loading || !isChecked}
|
||||
>
|
||||
Create Site
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
@ -4,7 +4,11 @@ import { useState, useRef } from "react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
export default function CopyTextBox({ text = "", wrapText = false }) {
|
||||
export default function CopyTextBox({
|
||||
text = "",
|
||||
wrapText = false,
|
||||
outline = true
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const textRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
|
@ -23,7 +27,9 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full border rounded-md bg-card">
|
||||
<div
|
||||
className={`relative w-full border rounded-md ${!outline ? "bg-muted" : "bg-card"}`}
|
||||
>
|
||||
<pre
|
||||
ref={textRef}
|
||||
className={`p-4 pr-16 text-sm w-full ${
|
||||
|
|
|
@ -20,18 +20,32 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2 max-w-full">
|
||||
{isLink ? (
|
||||
<Link
|
||||
href={text}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline mr-2"
|
||||
className="truncate hover:underline"
|
||||
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
|
||||
title={text} // Shows full text on hover
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="mr-2">{text}</span>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
}}
|
||||
title={text} // Full text tooltip
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -3,11 +3,11 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-md bg-card p-4">{children}</div>
|
||||
return <div className="border rounded-lg bg-card p-5">{children}</div>
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-0.5 pb-6">{children}</div>
|
||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>
|
||||
}
|
||||
|
||||
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
type SettingsSectionTitleProps = {
|
||||
title: string | React.ReactNode;
|
||||
description: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
size?: "2xl" | "1xl";
|
||||
};
|
||||
|
||||
export default function SettingsSectionTitle({
|
||||
title,
|
||||
description,
|
||||
size,
|
||||
size
|
||||
}: SettingsSectionTitleProps) {
|
||||
return (
|
||||
<div
|
||||
|
@ -20,7 +20,9 @@ export default function SettingsSectionTitle({
|
|||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,42 +2,59 @@
|
|||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
|
||||
import { useState } from "react";
|
||||
|
||||
interface StrategyOption {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
disabled?: boolean; // New optional property
|
||||
}
|
||||
|
||||
interface StrategySelectProps {
|
||||
options: StrategyOption[];
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
export function StrategySelect({
|
||||
options,
|
||||
defaultValue,
|
||||
onChange
|
||||
onChange,
|
||||
cols
|
||||
}: StrategySelectProps) {
|
||||
const [selected, setSelected] = useState(defaultValue);
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onChange}
|
||||
className="grid gap-4"
|
||||
onValueChange={(value) => {
|
||||
setSelected(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={option.id}
|
||||
data-state={
|
||||
selected === option.id ? "checked" : "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer rounded-lg border-2 p-4",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary"
|
||||
"relative flex rounded-lg border-2 p-4 transition-colors cursor-pointer",
|
||||
option.disabled
|
||||
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: selected === option.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-input hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={option.id}
|
||||
disabled={option.disabled}
|
||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="pl-7">
|
||||
|
|
|
@ -21,20 +21,26 @@ const buttonVariants = cva(
|
|||
secondary:
|
||||
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
squareOutlinePrimary:
|
||||
"border-2 border-primary bg-card hover:bg-primary/10 text-primary rounded-md",
|
||||
squareOutline:
|
||||
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md",
|
||||
squareDefault:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md",
|
||||
text: "",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
icon: "h-9 w-9"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import * as React from "react"
|
|||
import { cn } from "@app/lib/cn"
|
||||
|
||||
export function TableContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-md bg-card">{children}</div>
|
||||
return <div className="border rounded-lg bg-card">{children}</div>
|
||||
}
|
||||
|
||||
const Table = React.forwardRef<
|
||||
|
|
Loading…
Reference in a new issue