add new create site workflow

This commit is contained in:
miloschwartz 2025-03-16 15:20:19 -04:00
parent cdf904a2bc
commit edba818615
No known key found for this signature in database
16 changed files with 3416 additions and 283 deletions

View file

@ -41,7 +41,7 @@ gerbil:
rate_limits: rate_limits:
global: global:
window_minutes: 1 window_minutes: 1
max_requests: 100 max_requests: 500
{{if .EnableEmail}} {{if .EnableEmail}}
email: email:
smtp_host: "{{.EmailSMTPHost}}" smtp_host: "{{.EmailSMTPHost}}"

2670
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,7 @@
"glob": "11.0.0", "glob": "11.0.0",
"helmet": "8.0.0", "helmet": "8.0.0",
"http-errors": "2.0.0", "http-errors": "2.0.0",
"i": "^0.3.7",
"input-otp": "1.4.1", "input-otp": "1.4.1",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"lucide-react": "0.469.0", "lucide-react": "0.469.0",
@ -66,12 +67,14 @@
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "6.9.16", "nodemailer": "6.9.16",
"npm": "^11.2.0",
"oslo": "1.2.1", "oslo": "1.2.1",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-easy-sort": "^1.6.0", "react-easy-sort": "^1.6.0",
"react-hook-form": "7.54.2", "react-hook-form": "7.54.2",
"react-icons": "^5.5.0",
"rebuild": "0.1.2", "rebuild": "0.1.2",
"semver": "7.6.3", "semver": "7.6.3",
"tailwind-merge": "2.6.0", "tailwind-merge": "2.6.0",

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // 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 __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -110,23 +110,19 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return ( return (
<> <>
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10"> <div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
<div className="border-b"> <div className="container mx-auto flex flex-col content-between">
<div className="container mx-auto flex flex-col content-between"> <div className="my-4 px-3">
<div className="my-4"> <UserProvider user={user}>
<UserProvider user={user}> <Header orgId={params.orgId} orgs={orgs} />
<Header orgId={params.orgId} orgs={orgs} /> </UserProvider>
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div> </div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div> </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 pt-[155px]">
<div className="container mx-auto sm:px-0 px-3"> {children}
{children}
</div>
</div> </div>
</> </>
); );

View file

@ -331,7 +331,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
columns={columns} columns={columns}
data={rows} data={rows}
addSite={() => { addSite={() => {
setIsCreateModalOpen(true); router.push(`/${orgId}/settings/sites/create`);
}} }}
/> />
</> </>

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

View file

@ -6,7 +6,7 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider"; import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText } from "lucide-react"; import { BookOpenText, ExternalLink } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -46,9 +46,16 @@ export default async function RootLayout({
<span>Pangolin</span> <span>Pangolin</span>
</div> </div>
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<div className="whitespace-nowrap"> <a
Built by Fossorial href="https://fossorial.io/"
</div> 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" /> <Separator orientation="vertical" />
<a <a
href="https://github.com/fosrl/pangolin" href="https://github.com/fosrl/pangolin"

View file

@ -96,7 +96,8 @@ export default function StepperForm() {
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
setCurrentStep("site"); // setCurrentStep("site");
router.push(`/${values.orgId}/settings/sites/create`);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -290,42 +291,6 @@ export default function StepperForm() {
</form> </form>
</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> </section>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -4,7 +4,11 @@ import { useState, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react"; 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 [isCopied, setIsCopied] = useState(false);
const textRef = useRef<HTMLPreElement>(null); const textRef = useRef<HTMLPreElement>(null);
@ -23,7 +27,9 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
}; };
return ( 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 <pre
ref={textRef} ref={textRef}
className={`p-4 pr-16 text-sm w-full ${ className={`p-4 pr-16 text-sm w-full ${

View file

@ -20,18 +20,32 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
}; };
return ( return (
<div className="flex items-center"> <div className="flex items-center space-x-2 max-w-full">
{isLink ? ( {isLink ? (
<Link <Link
href={text} href={text}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} {text}
</Link> </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 <button
type="button" type="button"

View file

@ -3,11 +3,11 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
} }
export function SettingsSection({ 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 }) { 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 }) { export function SettingsSectionForm({ children }: { children: React.ReactNode }) {

View file

@ -1,13 +1,13 @@
type SettingsSectionTitleProps = { type SettingsSectionTitleProps = {
title: string | React.ReactNode; title: string | React.ReactNode;
description: string | React.ReactNode; description?: string | React.ReactNode;
size?: "2xl" | "1xl"; size?: "2xl" | "1xl";
}; };
export default function SettingsSectionTitle({ export default function SettingsSectionTitle({
title, title,
description, description,
size, size
}: SettingsSectionTitleProps) { }: SettingsSectionTitleProps) {
return ( return (
<div <div
@ -20,7 +20,9 @@ export default function SettingsSectionTitle({
> >
{title} {title}
</h2> </h2>
<p className="text-muted-foreground">{description}</p> {description && (
<p className="text-muted-foreground">{description}</p>
)}
</div> </div>
); );
} }

View file

@ -2,42 +2,59 @@
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { useState } from "react";
interface StrategyOption { interface StrategyOption {
id: string; id: string;
title: string; title: string;
description: string; description: string;
disabled?: boolean; // New optional property
} }
interface StrategySelectProps { interface StrategySelectProps {
options: StrategyOption[]; options: StrategyOption[];
defaultValue?: string; defaultValue?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
cols?: number;
} }
export function StrategySelect({ export function StrategySelect({
options, options,
defaultValue, defaultValue,
onChange onChange,
cols
}: StrategySelectProps) { }: StrategySelectProps) {
const [selected, setSelected] = useState(defaultValue);
return ( return (
<RadioGroup <RadioGroup
defaultValue={defaultValue} defaultValue={defaultValue}
onValueChange={onChange} onValueChange={(value) => {
className="grid gap-4" setSelected(value);
onChange?.(value);
}}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
> >
{options.map((option) => ( {options.map((option) => (
<label <label
key={option.id} key={option.id}
htmlFor={option.id} htmlFor={option.id}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn( className={cn(
"relative flex cursor-pointer rounded-lg border-2 p-4", "relative flex rounded-lg border-2 p-4 transition-colors cursor-pointer",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary" 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 <RadioGroupItem
value={option.id} value={option.id}
id={option.id} id={option.id}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary" className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/> />
<div className="pl-7"> <div className="pl-7">

View file

@ -21,20 +21,26 @@ const buttonVariants = cva(
secondary: secondary:
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80", "bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", 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: "", text: "",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline"
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3", sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-8", lg: "h-10 rounded-md px-8",
icon: "h-9 w-9", icon: "h-9 w-9"
} }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default"
}, }
} }
); );

View file

@ -3,7 +3,7 @@ import * as React from "react"
import { cn } from "@app/lib/cn" import { cn } from "@app/lib/cn"
export function TableContainer({ children }: { children: React.ReactNode }) { 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< const Table = React.forwardRef<