Merge branch 'dev' into feat/login-page-customization

This commit is contained in:
Fred KISSIE
2025-12-05 22:38:07 +01:00
275 changed files with 21920 additions and 6990 deletions

View File

@@ -1,17 +1,15 @@
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { cache } from "react";
import MemberResourcesPortal from "../../components/MemberResourcesPortal";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { ListUserOrgsResponse } from "@server/routers/org";
import MemberResourcesPortal from "@app/components/MemberResourcesPortal";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import EnvProvider from "@app/providers/EnvProvider";
import { orgLangingNavItems } from "@app/app/navigation";
import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
type OrgPageProps = {
params: Promise<{ orgId: string }>;
@@ -22,6 +20,10 @@ export default async function OrgPage(props: OrgPageProps) {
const orgId = params.orgId;
const env = pullEnv();
if (!orgId) {
redirect(`/`);
}
const getUser = cache(verifySession);
const user = await getUser();

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { ExitNodesDataTable } from "./ExitNodesDataTable";
import {
DropdownMenu,
@@ -99,9 +100,10 @@ export default function ExitNodesTable({
});
};
const columns: ColumnDef<RemoteExitNodeRow>[] = [
const columns: ExtendedColumnDef<RemoteExitNodeRow>[] = [
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -118,6 +120,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "online",
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
@@ -152,6 +155,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "type",
friendlyName: t("connectionType"),
header: ({ column }) => {
return (
<Button
@@ -178,6 +182,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "address",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
@@ -194,6 +199,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "endpoint",
friendlyName: "Endpoint",
header: ({ column }) => {
return (
<Button
@@ -230,11 +236,12 @@ export default function ExitNodesTable({
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => {
const nodeRow = row.original;
const remoteExitNodeId = nodeRow.id;
return (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">

View File

@@ -13,9 +13,5 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const { children } = props;
const env = pullEnv();
if (!env.flags.enableClients) {
redirect(`/${params.orgId}/settings`);
}
return children;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import {
SettingsContainer,
SettingsSection,
@@ -10,18 +10,23 @@ import {
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { PickClientDefaultsResponse } from "@server/routers/client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { useClientContext } from "@app/hooks/useClientContext";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import { build } from "@server/build";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { build } from "@server/build";
import { PickClientDefaultsResponse } from "@server/routers/client";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -32,7 +37,8 @@ export default function CredentialsPage() {
const { client } = useClientContext();
const [modalOpen, setModalOpen] = useState(false);
const [clientDefaults, setClientDefaults] = useState<PickClientDefaultsResponse | null>(null);
const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
@@ -44,18 +50,19 @@ export default function CredentialsPage() {
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const handleConfirmRegenerate = async () => {
const res = await api.get(`/org/${orgId}/pick-client-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
setClientDefaults(data);
await api.post(`/re-key/${client?.clientId}/regenerate-client-secret`, {
olmId: data.olmId,
secret: data.olmSecret,
});
await api.post(
`/re-key/${client?.clientId}/regenerate-client-secret`,
{
olmId: data.olmId,
secret: data.olmSecret
}
);
toast({
title: t("credentialsSaved"),
@@ -95,7 +102,8 @@ export default function CredentialsPage() {
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}>
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
@@ -121,4 +129,4 @@ export default function CredentialsPage() {
/>
</SettingsContainer>
);
}
}

View File

@@ -1,49 +1,40 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useClientContext } from "@app/hooks/useClientContext";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionFooter
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useClientContext } from "@app/hooks/useClientContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useEffect, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
siteIds: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
name: z.string().nonempty("Name is required")
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -54,15 +45,11 @@ export default function GeneralPage() {
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const router = useRouter();
const [sites, setSites] = useState<Tag[]>([]);
const [clientSites, setClientSites] = useState<Tag[]>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<number | null>(null);
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: client?.name,
siteIds: []
name: client?.name
},
mode: "onChange"
});
@@ -75,23 +62,6 @@ export default function GeneralPage() {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${client?.orgId}/sites/`
);
const availableSites = res.data.data.sites
.filter((s) => s.type === "newt" && s.subnet)
.map((site) => ({
id: site.siteId.toString(),
text: site.name
}));
setSites(availableSites);
// Filter sites to only include those assigned to the client
const assignedSites = availableSites.filter((site) =>
client?.siteIds?.includes(parseInt(site.id))
);
setClientSites(assignedSites);
// Set the default values for the form
form.setValue("siteIds", assignedSites);
} catch (e) {
toast({
variant: "destructive",
@@ -114,8 +84,7 @@ export default function GeneralPage() {
try {
await api.post(`/client/${client?.clientId}`, {
name: data.name,
siteIds: data.siteIds.map(site => parseInt(site.id))
name: data.name
});
updateClient({ name: data.name });
@@ -130,10 +99,7 @@ export default function GeneralPage() {
toast({
variant: "destructive",
title: t("clientUpdateFailed"),
description: formatAxiosError(
e,
t("clientUpdateError")
)
description: formatAxiosError(e, t("clientUpdateError"))
});
} finally {
setLoading(false);
@@ -173,39 +139,6 @@ export default function GeneralPage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>{t("sites")}</FormLabel>
<TagInput
{...field}
activeTagIndex={activeSitesTagIndex}
setActiveTagIndex={setActiveSitesTagIndex}
placeholder={t("selectSites")}
size="sm"
tags={form.getValues().siteIds}
setTags={(newTags) => {
form.setValue(
"siteIds",
newTags as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={sites}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
<FormDescription>
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
@@ -224,4 +157,4 @@ export default function GeneralPage() {
</SettingsSection>
</SettingsContainer>
);
}
}

View File

@@ -1,19 +1,19 @@
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetClientResponse } from "@server/routers/client";
import ClientInfoCard from "../../../../../components/ClientInfoCard";
import ClientProvider from "@app/providers/ClientProvider";
import { redirect } from "next/navigation";
import ClientInfoCard from "@app/components/ClientInfoCard";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import ClientProvider from "@app/providers/ClientProvider";
import { build } from "@server/build";
import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
type SettingsLayoutProps = {
children: React.ReactNode;
params: Promise<{ clientId: number | string; orgId: string }>;
}
};
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
@@ -36,16 +36,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t('general'),
href: `/{orgId}/settings/clients/{clientId}/general`
title: t("general"),
href: `/{orgId}/settings/clients/machine/{clientId}/general`
},
...(build === 'enterprise'
? [{
title: t('credentials'),
href: `/{orgId}/settings/clients/{clientId}/credentials`
},
]
: []),
...(build === "enterprise"
? [
{
title: t("credentials"),
href: `/{orgId}/settings/clients/machine/{clientId}/credentials`
}
]
: [])
];
return (
@@ -58,9 +59,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<ClientProvider client={client}>
<div className="space-y-6">
<ClientInfoCard />
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</ClientProvider>
</>

View File

@@ -4,5 +4,7 @@ export default async function ClientPage(props: {
params: Promise<{ orgId: string; clientId: number | string }>;
}) {
const params = await props.params;
redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`);
redirect(
`/${params.orgId}/settings/clients/machine/${params.clientId}/general`
);
}

View File

@@ -42,10 +42,7 @@ import {
FaFreebsd,
FaWindows
} from "react-icons/fa";
import {
SiNixos,
SiKubernetes
} from "react-icons/si";
import { SiNixos, SiKubernetes } from "react-icons/si";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -71,9 +68,11 @@ interface TunnelTypeOption {
disabled?: boolean;
}
type CommandItem = string | { title: string; command: string };
type Commands = {
unix: Record<string, string[]>;
windows: Record<string, string[]>;
unix: Record<string, CommandItem[]>;
windows: Record<string, CommandItem[]>;
};
const platforms = ["unix", "windows"] as const;
@@ -93,18 +92,7 @@ export default function Page() {
.min(2, { message: t("nameMin", { len: 2 }) })
.max(30, { message: t("nameMax", { len: 30 }) }),
method: z.enum(["olm"]),
siteIds: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.refine((val) => val.length > 0, {
message: t("siteRequired")
}),
subnet: z.union([z.ipv4(), z.ipv6()])
.refine((val) => val.length > 0, {
subnet: z.union([z.ipv4(), z.ipv6()]).refine((val) => val.length > 0, {
message: t("subnetRequired")
})
});
@@ -123,10 +111,6 @@ export default function Page() {
]);
const [loadingPage, setLoadingPage] = useState(true);
const [sites, setSites] = useState<Tag[]>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
number | null
>(null);
const [platform, setPlatform] = useState<Platform>("unix");
const [architecture, setArchitecture] = useState("All");
@@ -150,14 +134,26 @@ export default function Page() {
const commands = {
unix: {
All: [
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
{
title: t("install"),
command: `curl -fsSL https://pangolin.net/get-olm.sh | bash`
},
{
title: t("run"),
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
windows: {
x64: [
`curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`,
`olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
{
title: t("install"),
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
}
};
@@ -188,8 +184,8 @@ export default function Page() {
}
};
const getCommand = () => {
const placeholder = [t("unknownCommand")];
const getCommand = (): CommandItem[] => {
const placeholder: CommandItem[] = [t("unknownCommand")];
if (!commands) {
return placeholder;
}
@@ -236,12 +232,11 @@ export default function Page() {
}
};
const form = useForm({
const form = useForm<CreateClientFormValues>({
resolver: zodResolver(createClientFormSchema),
defaultValues: {
name: "",
method: "olm",
siteIds: [],
subnet: ""
}
});
@@ -262,7 +257,6 @@ export default function Page() {
const payload: CreateClientBody = {
name: data.name,
type: data.method as "olm",
siteIds: data.siteIds.map((site) => parseInt(site.id)),
olmId: clientDefaults.olmId,
secret: clientDefaults.olmSecret,
subnet: data.subnet
@@ -282,7 +276,7 @@ export default function Page() {
if (res && res.status === 201) {
const data = res.data.data;
router.push(`/${orgId}/settings/clients/${data.clientId}`);
router.push(`/${orgId}/settings/clients/machine/${data.clientId}`);
}
setCreateLoading(false);
@@ -294,18 +288,18 @@ export default function Page() {
// Fetch available sites
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
const sites = res.data.data.sites.filter(
(s) => s.type === "newt" && s.subnet
);
setSites(
sites.map((site) => ({
id: site.siteId.toString(),
text: site.name
}))
);
// const res = await api.get<AxiosResponse<ListSitesResponse>>(
// `/org/${orgId}/sites/`
// );
// const sites = res.data.data.sites.filter(
// (s) => s.type === "newt" && s.subnet
// );
// setSites(
// sites.map((site) => ({
// id: site.siteId.toString(),
// text: site.name
// }))
// );
let olmVersion = "latest";
@@ -331,7 +325,7 @@ export default function Page() {
const latestVersion = data.tag_name;
olmVersion = latestVersion;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (error instanceof Error && error.name === "AbortError") {
console.error(t("olmErrorFetchTimeout"));
} else {
console.error(
@@ -416,116 +410,68 @@ export default function Page() {
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("address")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t("subnetPlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("addressDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("sites")}
</FormLabel>
<TagInput
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
activeTagIndex={
activeSitesTagIndex
}
setActiveTagIndex={
setActiveSitesTagIndex
}
placeholder={t("selectSites")}
size="sm"
tags={
form.getValues()
.siteIds
}
setTags={(
olmags
) => {
form.setValue(
"siteIds",
olmags as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
sites
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
<FormDescription>
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"clientNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("clientAddress")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t(
"subnetPlaceholder"
)}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"addressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
@@ -537,7 +483,9 @@ export default function Page() {
{t("clientOlmCredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("clientOlmCredentialsDescription")}
{t(
"clientOlmCredentialsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -659,13 +607,43 @@ export default function Page() {
<p className="font-bold mb-3">
{t("commands")}
</p>
<div className="mt-2">
<CopyTextBox
text={getCommand().join(
"\n"
)}
outline={true}
/>
<div className="mt-2 space-y-3">
{getCommand().map(
(item, index) => {
const commandText =
typeof item ===
"string"
? item
: item.command;
const title =
typeof item ===
"string"
? undefined
: item.title;
return (
<div
key={index}
>
{title && (
<p className="text-sm font-medium mb-1.5">
{
title
}
</p>
)}
<CopyTextBox
text={
commandText
}
outline={
true
}
/>
</div>
);
}
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,78 @@
import type { ClientRow } from "@app/components/MachineClientsTable";
import MachineClientsTable from "@app/components/MachineClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListClientsResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
};
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let machineClients: ListClientsResponse["clients"] = [];
try {
const machineRes = await internal.get<
AxiosResponse<ListClientsResponse>
>(
`/org/${params.orgId}/clients?filter=machine`,
await authCookieHeader()
);
machineClients = machineRes.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const mapClientToRow = (
client: ListClientsResponse["clients"][0]
): ClientRow => {
return {
name: client.name,
id: client.clientId,
subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0),
mbOut: formatSize(client.megabytesOut || 0),
orgId: params.orgId,
online: client.online,
olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false,
userId: client.userId,
username: client.username,
userEmail: client.userEmail
};
};
const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow);
return (
<>
<SettingsSectionTitle
title={t("manageMachineClients")}
description={t("manageMachineClientsDescription")}
/>
<MachineClientsTable
machineClients={machineClientRows}
orgId={params.orgId}
/>
</>
);
}

View File

@@ -1,63 +1,13 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { ClientRow } from "../../../../components/ClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import ClientsTable from "../../../../components/ClientsTable";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
};
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let clients: ListClientsResponse["clients"] = [];
try {
const res = await internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients`,
await authCookieHeader()
);
clients = res.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const clientRows: ClientRow[] = clients.map((client) => {
return {
name: client.name,
id: client.clientId,
subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0),
mbOut: formatSize(client.megabytesOut || 0),
orgId: params.orgId,
online: client.online,
olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false,
};
});
return (
<>
<SettingsSectionTitle
title={t("manageClients")}
description={t("manageClientsDescription")}
/>
<ClientsTable clients={clientRows} orgId={params.orgId} />
</>
);
redirect(`/${params.orgId}/settings/clients/user`);
}

View File

@@ -0,0 +1,75 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import { getTranslations } from "next-intl/server";
import type { ClientRow } from "@app/components/MachineClientsTable";
import UserDevicesTable from "@app/components/UserDevicesTable";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let userClients: ListClientsResponse["clients"] = [];
try {
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients?filter=user`,
await authCookieHeader()
);
userClients = userRes.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const mapClientToRow = (
client: ListClientsResponse["clients"][0]
): ClientRow => {
return {
name: client.name,
id: client.clientId,
subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0),
mbOut: formatSize(client.megabytesOut || 0),
orgId: params.orgId,
online: client.online,
olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false,
userId: client.userId,
username: client.username,
userEmail: client.userEmail
};
};
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
return (
<>
<SettingsSectionTitle
title={t("manageUserDevices")}
description={t("manageUserDevicesDescription")}
/>
<UserDevicesTable
userClients={userClientRows}
orgId={params.orgId}
/>
</>
);
}

View File

@@ -323,29 +323,25 @@ export default function GeneralPage() {
</FormItem>
)}
/>
{env.flags.enableClients && (
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("subnet")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("subnetDescription")}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>{t("subnet")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("subnetDescription")}
</FormDescription>
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -82,7 +82,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<Layout
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections(env.flags.enableClients)}
navItems={orgNavSections()}
>
{children}
</Layout>

View File

@@ -466,7 +466,7 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<Link
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
>
<Button
variant="outline"
@@ -640,7 +640,7 @@ export default function GeneralPage() {
}}
defaultSort={{
id: "timestamp",
desc: false
desc: true
}}
// Server-side pagination props
totalCount={totalCount}

View File

@@ -493,7 +493,7 @@ export default function GeneralPage() {
}}
defaultSort={{
id: "timestamp",
desc: false
desc: true
}}
// Server-side pagination props
totalCount={totalCount}

View File

@@ -499,7 +499,7 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<Link
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
onClick={(e) => e.stopPropagation()}
>
<Button
@@ -778,7 +778,7 @@ export default function GeneralPage() {
}}
defaultSort={{
id: "timestamp",
desc: false
desc: true
}}
// Server-side pagination props
totalCount={totalCount}
@@ -793,4 +793,4 @@ export default function GeneralPage() {
/>
</>
);
}
}

View File

@@ -0,0 +1,92 @@
import type { InternalResourceRow } from "@app/components/ClientResourcesTable";
import ClientResourcesTable from "@app/components/ClientResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
export interface ClientResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
}
export default async function ClientResourcesPage(
props: ClientResourcesPageProps
) {
const params = await props.params;
const t = await getTranslations();
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
);
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 res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => {
return {
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null,
mode: siteResource.mode || ("port" as any),
// protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,
destination: siteResource.destination,
// destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null,
siteNiceId: siteResource.siteNiceId
};
}
);
return (
<>
<SettingsSectionTitle
title={t("clientResourceTitle")}
description={t("clientResourceDescription")}
/>
<OrgProvider org={org}>
<ClientResourcesTable
internalResources={internalResourceRows}
orgId={params.orgId}
defaultSort={{
id: "name",
desc: false
}}
/>
</OrgProvider>
</>
);
}

View File

@@ -1,146 +1,10 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import ResourcesTable, {
ResourceRow,
InternalResourceRow
} from "../../../../components/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 { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { toUnicode } from "punycode";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
};
export const dynamic = "force-dynamic";
export interface ResourcesPageProps {
params: Promise<{ orgId: string }>;
}
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>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
);
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 () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const resourceRows: ResourceRow[] = resources.map((resource) => {
return {
id: resource.resourceId,
name: resource.name,
orgId: params.orgId,
nice: resource.niceId,
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
authState: !resource.http
? "none"
: resource.sso ||
resource.pincodeId !== null ||
resource.passwordId !== null ||
resource.whitelist ||
resource.headerAuthId
? "protected"
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
ssl: resource.ssl,
targets: resource.targets?.map(target => ({
targetId: target.targetId,
ip: target.ip,
port: target.port,
enabled: target.enabled,
healthStatus: target.healthStatus
}))
};
});
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 (
<>
<SettingsSectionTitle
title={t("resourceTitle")}
description={t("resourceDescription")}
/>
<OrgProvider org={org}>
<ResourcesTable
resources={resourceRows}
internalResources={internalResourceRows}
orgId={params.orgId}
defaultView={
env.flags.enableClients ? defaultView : "proxy"
}
defaultSort={{
id: "name",
desc: false
}}
/>
</OrgProvider>
</>
);
}
const params = await props.params;
redirect(`/${params.orgId}/settings/resources/proxy`);
}

View File

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

View File

@@ -54,7 +54,7 @@ import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "../../../../../../components/DomainsTable";
import { DomainRow } from "@app/components/DomainsTable";
import { toASCII, toUnicode } from "punycode";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
@@ -235,7 +235,7 @@ export default function GeneralForm() {
});
if (data.niceId && data.niceId !== resource?.niceId) {
router.replace(`/${updated.orgId}/settings/resources/${data.niceId}/general`);
router.replace(`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`);
} else {
router.refresh();
}

View File

@@ -12,7 +12,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "../../../../../components/ResourceInfoBox";
import ResourceInfoBox from "@app/components/ResourceInfoBox";
import { GetSiteResponse } from "@server/routers/site";
import { getTranslations } from 'next-intl/server';
@@ -77,22 +77,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const navItems = [
{
title: t('general'),
href: `/{orgId}/settings/resources/{niceId}/general`
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
},
{
title: t('proxy'),
href: `/{orgId}/settings/resources/{niceId}/proxy`
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
}
];
if (resource.http) {
navItems.push({
title: t('authentication'),
href: `/{orgId}/settings/resources/{niceId}/authentication`
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
});
navItems.push({
title: t('rules'),
href: `/{orgId}/settings/resources/{niceId}/rules`
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
});
}

View File

@@ -5,6 +5,6 @@ export default async function ResourcePage(props: {
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/${params.niceId}/proxy`
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
);
}

View File

@@ -693,6 +693,7 @@ export default function ReverseProxyTargets(props: {
target.port <= 0 ||
isNaN(target.port)
);
console.log(targetsWithInvalidFields);
if (targetsWithInvalidFields.length > 0) {
toast({
variant: "destructive",
@@ -1139,7 +1140,7 @@ export default function ReverseProxyTargets(props: {
)}
{resource.http && (
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
@@ -1187,7 +1188,7 @@ export default function ReverseProxyTargets(props: {
}
}}
/>
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
@@ -1397,21 +1398,25 @@ export default function ReverseProxyTargets(props: {
.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>
)
(header) => {
const isActionsColumn = header.column.id === "actions";
return (
<TableHead
key={header.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
@@ -1424,21 +1429,25 @@ export default function ReverseProxyTargets(props: {
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
.map((cell) => {
const isActionsColumn = cell.column.id === "actions";
return (
<TableCell
key={
cell.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
@@ -1830,9 +1839,7 @@ export default function ReverseProxyTargets(props: {
undefined,
}}
onChanges={async (config) => {
console.log("here");
if (selectedTargetForHealthCheck) {
console.log(config);
updateTargetHealthCheck(
selectedTargetForHealthCheck.targetId,
config

View File

@@ -463,7 +463,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "action",
header: t('rulesAction'),
header: () => (<span className="p-3">{t('rulesAction')}</span>),
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
@@ -486,7 +486,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "match",
header: t('rulesMatchType'),
header: () => (<span className="p-3">{t('rulesMatchType')}</span>),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
@@ -510,7 +510,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "value",
header: t('value'),
header: () => (<span className="p-3">{t('value')}</span>),
cell: ({ row }) => (
row.original.match === "COUNTRY" ? (
<Popover>
@@ -571,7 +571,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "enabled",
header: t('enabled'),
header: () => (<span className="p-3">{t('enabled')}</span>),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
@@ -583,8 +583,9 @@ export default function ResourceRules(props: {
},
{
id: "actions",
header: () => (<span className="p-3">{t('actions')}</span>),
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<div className="flex items-center space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
@@ -829,7 +830,7 @@ export default function ResourceRules(props: {
/>
<Button
type="submit"
variant="secondary"
variant="outline"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
@@ -841,17 +842,23 @@ export default function ResourceRules(props: {
<TableHeader>
{table.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>
))}
{headerGroup.headers.map((header) => {
const isActionsColumn = header.column.id === "actions";
return (
<TableHead
key={header.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
@@ -859,14 +866,20 @@ export default function ResourceRules(props: {
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
{row.getVisibleCells().map((cell) => {
const isActionsColumn = cell.column.id === "actions";
return (
<TableCell
key={cell.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (

View File

@@ -98,7 +98,7 @@ import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "../../../../../components/DomainsTable";
import { DomainRow } from "@app/components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import {
Tooltip,
@@ -607,7 +607,7 @@ export default function Page() {
}
if (isHttp) {
router.push(`/${orgId}/settings/resources/${niceId}`);
router.push(`/${orgId}/settings/resources/proxy/${niceId}`);
} else {
const tcpUdpData = tcpUdpForm.getValues();
// Only show config snippets if enableProxy is explicitly true
@@ -616,7 +616,7 @@ export default function Page() {
router.refresh();
// } else {
// // If enableProxy is false or undefined, go directly to resource page
// router.push(`/${orgId}/settings/resources/${id}`);
// router.push(`/${orgId}/settings/resources/proxy/${id}`);
// }
}
}
@@ -780,7 +780,7 @@ export default function Page() {
const healthCheckColumn: ColumnDef<LocalTarget> = {
accessorKey: "healthCheck",
header: t("healthCheck"),
header: () => (<span className="p-3">{t("healthCheck")}</span>),
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
@@ -852,7 +852,7 @@ export default function Page() {
const matchPathColumn: ColumnDef<LocalTarget> = {
accessorKey: "path",
header: t("matchPath"),
header: () => (<span className="p-3">{t("matchPath")}</span>),
cell: ({ row }) => {
const hasPathMatch = !!(
row.original.path || row.original.pathMatchType
@@ -914,7 +914,7 @@ export default function Page() {
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: t("address"),
header: () => (<span className="p-3">{t("address")}</span>),
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
@@ -933,7 +933,7 @@ export default function Page() {
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input shadow-2xs rounded-md">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
@@ -1043,7 +1043,7 @@ export default function Page() {
)}
{isHttp && (
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
@@ -1091,7 +1091,7 @@ export default function Page() {
}
}}
/>
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
@@ -1128,7 +1128,7 @@ export default function Page() {
const rewritePathColumn: ColumnDef<LocalTarget> = {
accessorKey: "rewritePath",
header: t("rewritePath"),
header: () => (<span className="p-3">{t("rewritePath")}</span>),
cell: ({ row }) => {
const hasRewritePath = !!(
row.original.rewritePath || row.original.rewritePathType
@@ -1198,7 +1198,7 @@ export default function Page() {
const enabledColumn: ColumnDef<LocalTarget> = {
accessorKey: "enabled",
header: t("enabled"),
header: () => (<span className="p-3">{t("enabled")}</span>),
cell: ({ row }) => (
<div className="flex items-center justify-center w-full">
<Switch
@@ -1219,6 +1219,7 @@ export default function Page() {
const actionsColumn: ColumnDef<LocalTarget> = {
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => (
<div className="flex items-center justify-end w-full">
<Button
@@ -1594,23 +1595,27 @@ export default function Page() {
{headerGroup.headers.map(
(
header
) => (
<TableHead
key={
header.id
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
) => {
const isActionsColumn = header.column.id === "actions";
return (
<TableHead
key={
header.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
)
@@ -1633,21 +1638,25 @@ export default function Page() {
.map(
(
cell
) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
)
) => {
const isActionsColumn = cell.column.id === "actions";
return (
<TableCell
key={
cell.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
}
)}
</TableRow>
)
@@ -1895,7 +1904,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${niceId}/proxy`
`/${orgId}/settings/resources/proxy/${niceId}/proxy`
)
}
>

View File

@@ -0,0 +1,112 @@
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { toUnicode } from "punycode";
import { cache } from "react";
export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
}
export default async function ProxyResourcesPage(
props: ProxyResourcesPageProps
) {
const params = await props.params;
const t = await getTranslations();
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
);
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 () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const resourceRows: ResourceRow[] = resources.map((resource) => {
return {
id: resource.resourceId,
name: resource.name,
orgId: params.orgId,
nice: resource.niceId,
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
authState: !resource.http
? "none"
: resource.sso ||
resource.pincodeId !== null ||
resource.passwordId !== null ||
resource.whitelist ||
resource.headerAuthId
? "protected"
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
ssl: resource.ssl,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
ip: target.ip,
port: target.port,
enabled: target.enabled,
healthStatus: target.healthStatus
}))
};
});
return (
<>
<SettingsSectionTitle
title={t("proxyResourceTitle")}
description={t("proxyResourceDescription")}
/>
<OrgProvider org={org}>
<ProxyResourcesTable
resources={resourceRows}
orgId={params.orgId}
defaultSort={{
id: "name",
desc: false
}}
/>
</OrgProvider>
</>
);
}

View File

@@ -33,20 +33,11 @@ import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Tag, TagInput } from "@app/components/tags/tag-input";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(),
remoteSubnets: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -69,12 +60,6 @@ export default function GeneralPage() {
name: site?.name,
niceId: site?.niceId || "",
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
remoteSubnets: site?.remoteSubnets
? site.remoteSubnets.split(",").map((subnet, index) => ({
id: subnet.trim(),
text: subnet.trim()
}))
: []
},
mode: "onChange"
});
@@ -87,18 +72,12 @@ export default function GeneralPage() {
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets:
data.remoteSubnets
?.map((subnet) => subnet.text)
.join(",") || ""
});
updateSite({
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets:
data.remoteSubnets?.map((subnet) => subnet.text).join(",") || ""
});
if (data.niceId && data.niceId !== site?.niceId) {
@@ -174,64 +153,6 @@ export default function GeneralPage() {
)}
/>
{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
control={form.control}

View File

@@ -78,13 +78,15 @@ interface RemoteExitNodeOption {
disabled?: boolean;
}
type CommandItem = string | { title: string; command: string };
type Commands = {
unix: Record<string, string[]>;
windows: Record<string, string[]>;
docker: Record<string, string[]>;
kubernetes: Record<string, string[]>;
podman: Record<string, string[]>;
nixos: Record<string, string[]>;
unix: Record<string, CommandItem[]>;
windows: Record<string, CommandItem[]>;
docker: Record<string, CommandItem[]>;
kubernetes: Record<string, CommandItem[]>;
podman: Record<string, CommandItem[]>;
nixos: Record<string, CommandItem[]>;
};
const platforms = [
@@ -199,7 +201,7 @@ export default function Page() {
const [wgConfig, setWgConfig] = useState("");
const [createLoading, setCreateLoading] = useState(false);
const [acceptClients, setAcceptClients] = useState(false);
const [acceptClients, setAcceptClients] = useState(true);
const [newtVersion, setNewtVersion] = useState("latest");
const [siteDefaults, setSiteDefaults] =
@@ -238,24 +240,36 @@ PersistentKeepalive = 5`;
secret: string,
endpoint: string,
version: string,
acceptClients: boolean = false
acceptClients: boolean = true
) => {
const acceptClientsFlag = acceptClients ? " --accept-clients" : "";
const acceptClientsEnv = acceptClients
? "\n - ACCEPT_CLIENTS=true"
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
const acceptClientsEnv = !acceptClients
? "\n - DISABLE_CLIENTS=true"
: "";
const commands = {
unix: {
All: [
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
{
title: t("install"),
command: `curl -fsSL https://pangolin.net/get-newt.sh | bash`
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
]
},
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}${acceptClientsFlag}`
{
title: t("install"),
command: `curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`
},
{
title: t("run"),
command: `newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
]
},
docker: {
@@ -296,7 +310,7 @@ ContainerName=newt
Image=docker.io/fosrl/newt
Environment=PANGOLIN_ENDPOINT=${endpoint}
Environment=NEWT_ID=${id}
Environment=NEWT_SECRET=${secret}${acceptClients ? "\nEnvironment=ACCEPT_CLIENTS=true" : ""}
Environment=NEWT_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
# Secret=newt-secret,type=env,target=NEWT_SECRET
[Service]
@@ -356,8 +370,8 @@ WantedBy=default.target`
}
};
const getCommand = () => {
const placeholder = [t("unknownCommand")];
const getCommand = (): CommandItem[] => {
const placeholder: CommandItem[] = [t("unknownCommand")];
if (!commands) {
return placeholder;
}
@@ -409,7 +423,7 @@ WantedBy=default.target`
copied: false,
method: "newt",
clientAddress: "",
acceptClients: false,
acceptClients: true,
exitNodeId: undefined
}
});
@@ -679,109 +693,101 @@ WantedBy=default.target`
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4"
id="create-site-form"
>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{form.watch("method") === "newt" && (
<FormField
control={form.control}
name="name"
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
{t("siteAddress")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e.target
.value
);
field.onChange(
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{env.flags.enableClients &&
form.watch("method") ===
"newt" && (
<FormField
control={form.control}
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"siteAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
)}
</form>
</Form>
{tunnelTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">{t("type")}</span>
</div>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues(
"method"
)}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</>
)}
</SettingsSectionBody>
</SettingsSection>
{tunnelTypes.length > 1 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("tunnelType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("siteTunnelDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues("method")}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
)}
{form.watch("method") === "newt" && (
<>
<SettingsSection>
@@ -996,7 +1002,7 @@ WantedBy=default.target`
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground mb-4"
className="text-sm text-muted-foreground"
>
{t(
"siteAcceptClientConnectionsDescription"
@@ -1008,13 +1014,43 @@ WantedBy=default.target`
<p className="font-bold mb-3">
{t("commands")}
</p>
<div className="mt-2">
<CopyTextBox
text={getCommand().join(
"\n"
)}
outline={true}
/>
<div className="mt-2 space-y-3">
{getCommand().map(
(item, index) => {
const commandText =
typeof item ===
"string"
? item
: item.command;
const title =
typeof item ===
"string"
? undefined
: item.title;
return (
<div
key={index}
>
{title && (
<p className="text-sm font-medium mb-1.5">
{
title
}
</p>
)}
<CopyTextBox
text={
commandText
}
outline={
true
}
/>
</div>
);
}
)}
</div>
</div>
</div>

View File

@@ -1,6 +1,5 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -18,6 +17,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
export type GlobalUserRow = {
id: string;
@@ -66,9 +66,10 @@ export default function UsersTable({ users }: Props) {
});
};
const columns: ColumnDef<GlobalUserRow>[] = [
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -84,6 +85,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "username",
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
@@ -100,6 +102,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
@@ -116,6 +119,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -132,6 +136,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
@@ -148,6 +153,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
@@ -182,11 +188,21 @@ export default function UsersTable({ users }: Props) {
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2">
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -210,16 +226,6 @@ export default function UsersTable({ users }: Props) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
size="sm"
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</>
);
@@ -238,13 +244,9 @@ export default function UsersTable({ users }: Props) {
}}
dialog={
<div>
<p>
{t("userQuestionRemove")}
</p>
<p>{t("userQuestionRemove")}</p>
<p>
{t("userMessageRemove")}
</p>
<p>{t("userMessageRemove")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}

View File

@@ -154,7 +154,7 @@ export default async function OrgAuthPage(props: {
</Link>
</span>
</div>
<Card className="shadow-md w-full max-w-md">
<Card className="w-full max-w-md">
<CardHeader>
{branding?.logoUrl && (
<div className="flex flex-row items-center justify-center mb-3">

View File

@@ -1,5 +1,13 @@
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { AxiosResponse } from "axios";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
export const metadata: Metadata = {
title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -11,15 +19,117 @@ type AuthLayoutProps = {
};
export default async function AuthLayout({ children }: AuthLayoutProps) {
const env = pullEnv();
const t = await getTranslations();
let hideFooter = false;
if (build == "enterprise") {
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
if (
env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid
) {
hideFooter = true;
}
}
return (
<div className="h-full flex flex-col">
<div className="flex justify-end items-center p-3 space-x-2">
<ThemeSwitcher />
</div>
<div className="flex-1 flex items-center justify-center">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!hideFooter && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-xs text-neutral-400 dark:text-neutral-600">
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
© {new Date().getFullYear()} Fossorial, Inc.
</span>
</a>
<Separator orientation="vertical" />
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
{process.env.BRANDING_APP_NAME || "Pangolin"}
</span>
</a>
<Separator orientation="vertical" />
<span>
{build === "oss"
? t("communityEdition")
: build === "enterprise"
? t("enterpriseEdition")
: t("pangolinCloud")}
</span>
{build === "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("terms")}</span>
</a>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("privacy")}</span>
</a>
</>
)}
<Separator orientation="vertical" />
<a
href="https://docs.pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("docs")}</span>
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("github")}</span>
</a>
</div>
</footer>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import DeviceLoginForm from "@/components/DeviceLoginForm";
import { cache } from "react";
export const dynamic = "force-dynamic";
type Props = {
searchParams: Promise<{ code?: string }>;
};
export default async function DeviceLoginPage({ searchParams }: Props) {
const user = await verifySession({ forceLogin: true });
const params = await searchParams;
const code = params.code || "";
if (!user) {
const redirectDestination = code
? `/auth/login/device?code=${encodeURIComponent(code)}`
: "/auth/login/device";
redirect(`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`);
}
const userName = user?.name || user?.username || "";
return (
<DeviceLoginForm
userEmail={user?.email || ""}
userName={userName}
initialCode={code}
/>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import BrandingLogo from "@app/components/BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { CheckCircle2 } from "lucide-react";
import { useTranslations } from "next-intl";
export default function DeviceAuthSuccessPage() {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card>
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{t("deviceActivation")}</p>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="flex flex-col items-center space-y-4">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div className="space-y-2">
<h3 className="text-xl font-bold text-center">
{t("deviceConnected")}
</h3>
<p className="text-center text-sm text-muted-foreground">
{t("deviceAuthorizedMessage")}
</p>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -25,12 +25,14 @@ export default async function Page(props: {
const user = await getUser({ skipCheckVerifyEmail: true });
const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin;
const forceLogin = forceLoginParam === "true";
const env = pullEnv();
const signUpDisabled = env.flags.disableSignupWithoutInvite;
if (user) {
if (user && !forceLogin) {
redirect("/");
}
@@ -53,7 +55,7 @@ export default async function Page(props: {
if (loginPageDomain) {
const redirectUrl = searchParams.redirect as string | undefined;
let url = `https://${loginPageDomain}/auth/org`;
if (redirectUrl) {
url += `?redirect=${redirectUrl}`;
@@ -96,7 +98,7 @@ export default async function Page(props: {
</div>
)}
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} forceLogin={forceLogin} />
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">

View File

@@ -20,7 +20,7 @@ import { Toaster } from "@app/components/ui/toaster";
import { build } from "@server/build";
import { TopLoader } from "@app/components/Toploader";
import Script from "next/script";
import { ReactQueryProvider } from "@app/components/react-query-provider";
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
export const metadata: Metadata = {
@@ -96,16 +96,16 @@ export default async function RootLayout({
strategy="afterInteractive"
/>
)}
<ReactQueryProvider>
<NextIntlClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeDataProvider colors={loadBrandingColors()}>
<EnvProvider env={pullEnv()}>
<NextIntlClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeDataProvider colors={loadBrandingColors()}>
<EnvProvider env={env}>
<TanstackQueryProvider>
<LicenseStatusProvider
licenseStatus={licenseStatus}
>
@@ -125,11 +125,11 @@ export default async function RootLayout({
</SupportStatusProvider>
</LicenseStatusProvider>
<Toaster />
</EnvProvider>
</ThemeDataProvider>
</ThemeProvider>
</NextIntlClientProvider>
</ReactQueryProvider>
</TanstackQueryProvider>
</EnvProvider>
</ThemeDataProvider>
</ThemeProvider>
</NextIntlClientProvider>
{process.env.NODE_ENV === "development" && (
<TailwindIndicator />

View File

@@ -18,6 +18,9 @@ import {
Logs,
SquareMousePointer,
ScanEye,
GlobeLock,
Smartphone,
Laptop,
ChartLine
} from "lucide-react";
@@ -32,42 +35,58 @@ export const orgLangingNavItems: SidebarNavItem[] = [
{
title: "sidebarAccount",
href: "/{orgId}",
icon: <User className="h-4 w-4" />
icon: <User className="size-4 flex-none" />
}
];
export const orgNavSections = (
enableClients: boolean = true
): SidebarNavSection[] => [
export const orgNavSections = (): SidebarNavSection[] => [
{
heading: "sidebarGeneral",
items: [
{
title: "sidebarSites",
href: "/{orgId}/settings/sites",
icon: <Combine className="h-4 w-4" />
icon: <Combine className="size-4 flex-none" />
},
{
title: "sidebarResources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
icon: <Waypoints className="size-4 flex-none" />,
items: [
{
title: "sidebarProxyResources",
href: "/{orgId}/settings/resources/proxy",
icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarClientResources",
href: "/{orgId}/settings/resources/client",
icon: <GlobeLock className="size-4 flex-none" />
}
]
},
...(enableClients
? [
{
title: "sidebarClients",
href: "/{orgId}/settings/clients",
icon: <MonitorUp className="h-4 w-4" />,
isBeta: true
}
]
: []),
...(build === "saas"
{
title: "sidebarClients",
icon: <MonitorUp className="size-4 flex-none" />,
isBeta: true,
items: [
{
href: "/{orgId}/settings/clients/user",
title: "sidebarUserDevices",
icon: <Laptop className="size-4 flex-none" />
},
{
href: "/{orgId}/settings/clients/machine",
title: "sidebarMachineClients",
icon: <Server className="size-4 flex-none" />
}
]
},
...(build == "saas"
? [
{
title: "sidebarRemoteExitNodes",
href: "/{orgId}/settings/remote-exit-nodes",
icon: <Server className="h-4 w-4" />,
icon: <Server className="size-4 flex-none" />,
showEE: true
}
]
@@ -75,39 +94,45 @@ export const orgNavSections = (
{
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
icon: <Globe className="h-4 w-4" />
icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="h-4 w-4" />
icon: <ReceiptText className="size-4 flex-none" />
}
]
},
{
heading: "sidebarAccessControl",
heading: "accessControls",
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="h-4 w-4" />
icon: <User className="size-4 flex-none" />,
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="size-4 flex-none" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
icon: <TicketCheck className="size-4 flex-none" />
}
]
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="h-4 w-4" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
icon: <TicketCheck className="h-4 w-4" />
icon: <Users className="size-4 flex-none" />
},
...(build == "saas"
? [
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="h-4 w-4" />,
icon: <Fingerprint className="size-4 flex-none" />,
showEE: true
}
]
@@ -115,38 +140,56 @@ export const orgNavSections = (
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
icon: <LinkIcon className="size-4 flex-none" />
}
]
},
{
heading: "sidebarLogAndAnalytics",
items: [
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="h-4 w-4" />
},
{
heading: "sidebarLogsAndAnalytics",
items: (() => {
const logItems: SidebarNavItem[] = [
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" />
},
...(build != "oss"
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
];
const analytics = {
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="h-4 w-4" />
},
...(build != "oss"
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="h-4 w-4" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="h-4 w-4" />
}
]
: [])
]
};
// If only one log item, return it directly without grouping
if (logItems.length === 1) {
return [analytics, ...logItems];
}
// If multiple log items, create a group
return [
analytics,
{
title: "sidebarLogs",
icon: <Logs className="size-4 flex-none" />,
items: logItems
}
];
})()
},
{
heading: "sidebarOrganization",
@@ -154,14 +197,14 @@ export const orgNavSections = (
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />
icon: <KeyRound className="size-4 flex-none" />
},
...(build == "saas"
? [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <CreditCard className="h-4 w-4" />
icon: <CreditCard className="size-4 flex-none" />
}
]
: []),
@@ -170,14 +213,14 @@ export const orgNavSections = (
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: <TicketCheck className="h-4 w-4" />
icon: <TicketCheck className="size-4 flex-none" />
}
]
: []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="h-4 w-4" />
icon: <Settings className="size-4 flex-none" />
}
]
}
@@ -185,29 +228,29 @@ export const orgNavSections = (
export const adminNavSections: SidebarNavSection[] = [
{
heading: "Admin",
heading: "sidebarAdmin",
items: [
{
title: "sidebarAllUsers",
href: "/admin/users",
icon: <Users className="h-4 w-4" />
icon: <Users className="size-4 flex-none" />
},
{
title: "sidebarApiKeys",
href: "/admin/api-keys",
icon: <KeyRound className="h-4 w-4" />
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarIdentityProviders",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
icon: <Fingerprint className="size-4 flex-none" />
},
...(build == "enterprise"
? [
{
title: "sidebarLicense",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
icon: <TicketCheck className="size-4 flex-none" />
}
]
: [])

View File

@@ -80,7 +80,7 @@ export default async function Page(props: {
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
if (lastOrgExists) {
if (lastOrgExists && lastOrgCookie) {
redirect(`/${lastOrgCookie}`);
} else {
let ownedOrg = orgs.find((org) => org.isOwner);

View File

@@ -76,8 +76,8 @@ export default function StepperForm() {
} catch (e) {
console.error("Failed to fetch default subnet:", e);
toast({
title: "Error",
description: "Failed to fetch default subnet",
title: t("error"),
description: t("setupFailedToFetchSubnet"),
variant: "destructive"
});
}
@@ -296,31 +296,27 @@ export default function StepperForm() {
)}
/>
{env.flags.enableClients && (
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
Subnet
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Network subnet for this
organization. A default
value has been provided.
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupSubnetAdvanced")}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("setupSubnetDescription")}
</FormDescription>
</FormItem>
)}
/>
{orgIdTaken && !orgCreated ? (
<Alert variant="destructive">