Merge branch 'dev' into feat/paginate-user-roles-table

This commit is contained in:
miloschwartz
2026-04-20 21:27:51 -07:00
362 changed files with 13561 additions and 4286 deletions

View File

@@ -12,6 +12,11 @@ import type { ListRolesResponse } from "@server/routers/role";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Approvals"
};
export interface ApprovalFeedPageProps {
params: Promise<{ orgId: string }>;

View File

@@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { build } from "@server/build";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Billing"
};
type BillingSettingsProps = {
children: React.ReactNode;

View File

@@ -477,7 +477,7 @@ export default function BillingPage() {
};
const handleContactUs = () => {
window.open("https://pangolin.net/talk-to-us", "_blank");
window.open("https://pangolin.net/contact", "_blank");
};
// Get current plan ID from tier
@@ -491,6 +491,10 @@ export default function BillingPage() {
const currentPlanId = getCurrentPlanId();
const visiblePlanOptions = planOptions.filter(
(plan) => plan.id !== "home" || currentPlanId === "home"
);
// Check if subscription is in a problematic state that requires attention
const hasProblematicSubscription = (): boolean => {
if (!tierSubscription?.subscription) return false;
@@ -554,6 +558,14 @@ export default function BillingPage() {
// Get button label and action for each plan
const getPlanAction = (plan: PlanOption) => {
if (plan.id === "enterprise") {
if (plan.id === currentPlanId) {
return {
label: "Manage Current Plan",
action: handleModifySubscription,
variant: "default" as const,
disabled: false
};
}
return {
label: "Contact Us",
action: handleContactUs,
@@ -803,8 +815,8 @@ export default function BillingPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Plan Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{planOptions.map((plan) => {
<div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
{visiblePlanOptions.map((plan) => {
const isCurrentPlan = plan.id === currentPlanId;
const planAction = getPlanAction(plan);

View File

@@ -97,7 +97,8 @@ export default function GeneralPage() {
emailPath: z.string().nullable().optional(),
namePath: z.string().nullable().optional(),
scopes: z.string().min(1, { message: t("idpScopeRequired") }),
autoProvision: z.boolean().default(false)
autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
});
// Google form schema (simplified)
@@ -109,7 +110,8 @@ export default function GeneralPage() {
.min(1, { message: t("idpClientSecretRequired") }),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false)
autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
});
// Azure form schema (simplified with tenant ID)
@@ -122,7 +124,8 @@ export default function GeneralPage() {
tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false)
autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
});
type OidcFormValues = z.infer<typeof OidcFormSchema>;
@@ -160,7 +163,8 @@ export default function GeneralPage() {
autoProvision: true,
roleMapping: null,
roleId: null,
tenantId: ""
tenantId: "",
orgMapping: ""
}
});
@@ -227,7 +231,8 @@ export default function GeneralPage() {
clientSecret: data.idpOidcConfig.clientSecret,
autoProvision: data.idp.autoProvision,
roleMapping: roleMapping || null,
roleId: null
roleId: null,
orgMapping: data.idpOrg?.orgMapping ?? ""
};
// Add variant-specific fields
@@ -344,12 +349,14 @@ export default function GeneralPage() {
}
// Build payload based on variant
const orgMappingTrimmed = data.orgMapping?.trim() ?? "";
let payload: any = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
autoProvision: data.autoProvision,
roleMapping: roleMappingExpression
roleMapping: roleMappingExpression,
orgMapping: orgMappingTrimmed === "" ? null : orgMappingTrimmed
};
// Add variant-specific fields
@@ -532,6 +539,10 @@ export default function GeneralPage() {
}
rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
/>
</form>
</Form>

View File

@@ -6,6 +6,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Identity Provider"
};
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Identity Provider"
};
export default async function IdpPage(props: {
params: Promise<{ orgId: string; idpId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Identity Provider"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -91,7 +91,8 @@ export default function Page() {
tenantId: z.string().optional(),
autoProvision: z.boolean().default(false),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional()
roleId: z.number().nullable().optional(),
orgMapping: z.string().optional()
});
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
@@ -112,7 +113,8 @@ export default function Page() {
tenantId: "",
autoProvision: false,
roleMapping: null,
roleId: null
roleId: null,
orgMapping: ""
}
});
@@ -177,7 +179,7 @@ export default function Page() {
return;
}
const payload = {
const payload: Record<string, unknown> = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
@@ -191,6 +193,10 @@ export default function Page() {
scopes: data.scopes,
variant: data.type
};
const trimmedOrgMapping = data.orgMapping?.trim();
if (trimmedOrgMapping) {
payload.orgMapping = trimmedOrgMapping;
}
// Use the appropriate endpoint based on provider type
const endpoint = "oidc";
@@ -336,6 +342,10 @@ export default function Page() {
}
rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
/>
</form>
</Form>

View File

@@ -7,6 +7,11 @@ import { getTranslations } from "next-intl/server";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Identity Providers"
};
type OrgIdpPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -3,6 +3,11 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
import { AxiosResponse } from "axios";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Enterprise Licenses"
};
type Props = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Remote Exit Node"
};
export default async function RemoteExitNodePage(props: {
params: Promise<{ orgId: string; remoteExitNodeId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Remote Exit Node"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -7,6 +7,11 @@ import ExitNodesTable, {
} from "@app/components/ExitNodesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Remote Exit Nodes"
};
type RemoteExitNodesPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -3,15 +3,19 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import InvitationsTable, {
InvitationRow
} from "../../../../../components/InvitationsTable";
} from "@app/components/InvitationsTable";
import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Invitations"
};
type InvitationsPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Access"
};
type AccessPageProps = {
params: Promise<{ orgId: string }>;
};

View File

@@ -8,6 +8,11 @@ import RolesTable, { type RoleRow } from "@app/components/RolesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Roles"
};
type RolesPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -8,6 +8,11 @@ import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User"
};
interface UserLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "User"
};
export default async function UserPage(props: {
params: Promise<{ orgId: string; userId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create User"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -46,7 +46,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import Image from "next/image";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
@@ -152,31 +152,8 @@ export default function Page() {
const getIdpIcon = (variant: string | null) => {
if (!variant) return null;
switch (variant.toLowerCase()) {
case "google":
return (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
);
case "azure":
return (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
);
default:
return null;
}
const type = variant.toLowerCase();
return <IdpTypeIcon type={type} size={24} />;
};
const validFor = [
@@ -340,15 +317,16 @@ export default function Page() {
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const res = await api.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`,
{
email: values.email,
roleIds,
validHours: parseInt(values.validForHours),
sendEmail
}
)
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`,
{
email: values.email,
roleIds,
validHours: parseInt(values.validForHours),
sendEmail
}
)
.catch((e) => {
if (e.response?.status === 409) {
toast({
@@ -489,7 +467,7 @@ export default function Page() {
<div>
<SettingsContainer>
{!inviteLink ? (
{!inviteLink && userOptions.length > 1 ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -512,7 +490,7 @@ export default function Page() {
genericOidcForm.reset();
}
}}
cols={2}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -11,6 +11,11 @@ import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Users"
};
type UsersPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -7,6 +7,11 @@ import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "API Key"
};
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "API Key"
};
export default async function ApiKeysPage(props: {
params: Promise<{ orgId: string; apiKeyId: string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create API Key"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -2,11 +2,14 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, {
OrgApiKeyRow
} from "../../../../components/OrgApiKeysTable";
import OrgApiKeysTable, { OrgApiKeyRow } from "@app/components/OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "API Keys"
};
type ApiKeyPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -17,7 +17,7 @@ type BluePrintsPageProps = {
};
export const metadata: Metadata = {
title: "Blueprint Detail"
title: "Edit Blueprint"
};
export default async function BluePrintDetailPage(props: BluePrintsPageProps) {

View File

@@ -12,7 +12,7 @@ export interface CreateBlueprintPageProps {
}
export const metadata: Metadata = {
title: "Create blueprint"
title: "Create Blueprint"
};
export default async function CreateBlueprintPage(

View File

@@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Machine Client"
};
type SettingsLayoutProps = {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Machine Client"
};
export default async function ClientPage(props: {
params: Promise<{ orgId: string; niceId: number | string }>;
}) {

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Machine Client"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -8,6 +8,11 @@ import { ListClientsResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Pagination } from "@server/types/Pagination";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Machine Clients"
};
type ClientsPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Clients"
};
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;

View File

@@ -8,6 +8,11 @@ import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User Device"
};
type SettingsLayoutProps = {
children: React.ReactNode;

View File

@@ -1,10 +1,13 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "User Device"
};
export default async function ClientPage(props: {
params: Promise<{ orgId: string; niceId: number | string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/clients/user/${params.niceId}/general`
);
redirect(`/${params.orgId}/settings/clients/user/${params.niceId}/general`);
}

View File

@@ -7,6 +7,11 @@ import { type ListUserDevicesResponse } from "@server/routers/client";
import type { Pagination } from "@server/types/Pagination";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "User Devices"
};
type ClientsPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,15 +1,13 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainInfoCard from "@app/components/DomainInfoCard";
import RestartDomainButton from "@app/components/RestartDomainButton";
import DomainPageClient from "@app/components/DomainPageClient";
import { GetDomainResponse } from "@server/routers/domain/getDomain";
import { pullEnv } from "@app/lib/pullEnv";
import { getTranslations } from "next-intl/server";
import RefreshButton from "@app/components/RefreshButton";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Domain"
};
interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>;
@@ -19,8 +17,6 @@ export default async function DomainSettingsPage({
params
}: DomainSettingsPageProps) {
const { domainId, orgId } = await params;
const t = await getTranslations();
const env = pullEnv();
let domain: GetDomainResponse | null = null;
try {
@@ -33,55 +29,27 @@ export default async function DomainSettingsPage({
return null;
}
let dnsRecords;
let dnsRecords: GetDNSRecordsResponse | null = null;
try {
const response = await internal.get(
`/org/${orgId}/domain/${domainId}/dns-records`,
await authCookieHeader()
);
dnsRecords = response.data.data;
} catch (error) {
} catch {
return null;
}
if (!domain) {
if (!domain || !dnsRecords) {
return null;
}
return (
<>
<div className="flex justify-between">
<SettingsSectionTitle
title={domain.baseDomain}
description={t("domainSettingDescription")}
/>
{env.flags.usePangolinDns && domain.failed ? (
<RestartDomainButton
orgId={orgId}
domainId={domain.domainId}
/>
) : (
<RefreshButton />
)}
</div>
<div className="space-y-6">
<DomainInfoCard
failed={domain.failed}
verified={domain.verified}
type={domain.type}
errorMessage={domain.errorMessage}
/>
<DNSRecordsTable records={dnsRecords} type={domain.type} />
{domain.type == "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
domainId={domain.domainId}
domain={domain}
/>
)}
</div>
</>
<DomainPageClient
initialDomain={domain}
initialDnsRecords={dnsRecords}
orgId={orgId}
domainId={domainId}
/>
);
}
}

View File

@@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainsTable, { DomainRow } from "../../../../components/DomainsTable";
import DomainsTable, { DomainRow } from "@app/components/DomainsTable";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
@@ -11,6 +11,11 @@ import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain";
import { toUnicode } from "punycode";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Domains"
};
type Props = {
params: Promise<{ orgId: string }>;

View File

@@ -11,6 +11,7 @@ import {
GetLoginPageResponse
} from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export interface AuthPageProps {

View File

@@ -11,6 +11,11 @@ import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
import { build } from "@server/build";
import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Organization"
};
type GeneralSettingsProps = {
children: React.ReactNode;

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Access Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Action Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -2,6 +2,11 @@ import { LogAnalyticsData } from "@app/components/LogAnalyticsData";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import { Suspense } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Log Analytics"
};
export interface AnalyticsPageProps {
params: Promise<{ orgId: string }>;

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Connection Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -491,7 +491,7 @@ export default function ConnectionLogsPage() {
);
},
cell: ({ row }) => {
const clientType = row.original.clientType === "olm" ? "machine" : "user";
const clientType = row.original.userId ? "user" : "machine";
if (row.original.clientName && row.original.clientNiceId) {
return (
<Link

View File

@@ -1,3 +1,9 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Logs"
};
export default function GeneralPage() {
return null;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Request Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Streaming Logs"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,485 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Switch } from "@app/components/ui/switch";
import { Globe, MoreHorizontal, Plus } from "lucide-react";
import { AxiosResponse } from "axios";
import { build } from "@server/build";
import Image from "next/image";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Destination,
HttpDestinationCredenza,
parseHttpConfig
} from "@app/components/HttpDestinationCredenza";
import { useTranslations } from "next-intl";
// ── Re-export Destination so the rest of the file can use it ──────────────────
interface ListDestinationsResponse {
destinations: Destination[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
// ── Destination card ───────────────────────────────────────────────────────────
interface DestinationCardProps {
destination: Destination;
onToggle: (id: number, enabled: boolean) => void;
onEdit: (destination: Destination) => void;
onDelete: (destination: Destination) => void;
isToggling: boolean;
disabled?: boolean;
}
function DestinationCard({
destination,
onToggle,
onEdit,
onDelete,
isToggling,
disabled = false
}: DestinationCardProps) {
const t = useTranslations();
const cfg = parseHttpConfig(destination.config);
return (
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
{/* Top row: icon + name/type + toggle */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Squirkle icon: gray outer → white inner → black globe */}
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
<Globe className="h-3.5 w-3.5 text-black" />
</div>
</div>
<div className="min-w-0">
<p className="font-semibold text-sm leading-tight truncate">
{cfg.name || t("streamingUnnamedDestination")}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
HTTP
</p>
</div>
</div>
<Switch
checked={destination.enabled}
onCheckedChange={(v) =>
onToggle(destination.destinationId, v)
}
disabled={isToggling || disabled}
className="shrink-0 mt-0.5"
/>
</div>
{/* URL preview */}
<p className="text-xs text-muted-foreground truncate">
{cfg.url || (
<span className="italic">
{t("streamingNoUrlConfigured")}
</span>
)}
</p>
{/* Footer: edit button + three-dots menu */}
<div className="mt-auto pt-5 flex gap-2">
<Button
variant="outline"
onClick={() => onEdit(destination)}
disabled={disabled}
className="flex-1"
>
{t("edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
disabled={disabled}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(destination)}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
// ── Add destination card ───────────────────────────────────────────────────────
function AddDestinationCard({ onClick }: { onClick: () => void }) {
const t = useTranslations();
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-transparent transition-colors p-5 min-h-35 w-full text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 cursor-pointer"
>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
<Plus className="h-4 w-4" />
</div>
<span className="text-sm font-medium">
{t("streamingAddDestination")}
</span>
</div>
</button>
);
}
// ── Destination type picker ────────────────────────────────────────────────────
type DestinationType = "http" | "s3" | "datadog";
interface DestinationTypePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (type: DestinationType) => void;
isPaywalled?: boolean;
}
function DestinationTypePicker({
open,
onOpenChange,
onSelect,
isPaywalled = false
}: DestinationTypePickerProps) {
const t = useTranslations();
const [selected, setSelected] = useState<DestinationType>("http");
const destinationTypeOptions: ReadonlyArray<
StrategyOption<DestinationType>
> = [
{
id: "http",
title: t("streamingHttpWebhookTitle"),
description: t("streamingHttpWebhookDescription"),
icon: <Globe className="h-6 w-6" />
},
{
id: "s3",
title: t("streamingS3Title"),
description: t("streamingS3Description"),
disabled: true,
icon: (
<Image
src="/third-party/s3.png"
alt={t("streamingS3Title")}
width={24}
height={24}
className="rounded-sm"
/>
)
},
{
id: "datadog",
title: t("streamingDatadogTitle"),
description: t("streamingDatadogDescription"),
disabled: true,
icon: (
<Image
src="/third-party/dd.png"
alt={t("streamingDatadogTitle")}
width={24}
height={24}
className="rounded-sm"
/>
)
}
];
useEffect(() => {
if (open) setSelected("http");
}, [open]);
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-lg">
<CredenzaHeader>
<CredenzaTitle>
{t("streamingAddDestination")}
</CredenzaTitle>
<CredenzaDescription>
{t("streamingTypePickerDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div
className={
isPaywalled ? "pointer-events-none opacity-50" : ""
}
>
<StrategySelect
options={destinationTypeOptions}
value={selected}
onChange={setSelected}
cols={1}
/>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => onSelect(selected)}
disabled={isPaywalled}
>
{t("continue")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
// ── Main page ──────────────────────────────────────────────────────────────────
export default function StreamingDestinationsPage() {
const { orgId } = useParams() as { orgId: string };
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const isEnterprise = isPaidUser(tierMatrix[TierFeature.SIEM]);
const t = useTranslations();
const [destinations, setDestinations] = useState<Destination[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [typePickerOpen, setTypePickerOpen] = useState(false);
const [editingDestination, setEditingDestination] =
useState<Destination | null>(null);
const [togglingIds, setTogglingIds] = useState<Set<number>>(new Set());
// Delete state
const [deleteTarget, setDeleteTarget] = useState<Destination | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const loadDestinations = useCallback(async () => {
if (build == "oss") {
setDestinations([]);
setLoading(false);
return;
}
try {
const res = await api.get<AxiosResponse<ListDestinationsResponse>>(
`/org/${orgId}/event-streaming-destinations`
);
setDestinations(res.data.data.destinations ?? []);
} catch (e) {
toast({
variant: "destructive",
title: t("streamingFailedToLoad"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setLoading(false);
}
}, [orgId]);
useEffect(() => {
loadDestinations();
}, [loadDestinations]);
const handleToggle = async (destinationId: number, enabled: boolean) => {
// Optimistic update
setDestinations((prev) =>
prev.map((d) =>
d.destinationId === destinationId ? { ...d, enabled } : d
)
);
setTogglingIds((prev) => new Set(prev).add(destinationId));
try {
await api.post(
`/org/${orgId}/event-streaming-destination/${destinationId}`,
{ enabled }
);
} catch (e) {
// Revert on failure
setDestinations((prev) =>
prev.map((d) =>
d.destinationId === destinationId
? { ...d, enabled: !enabled }
: d
)
);
toast({
variant: "destructive",
title: t("streamingFailedToUpdate"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setTogglingIds((prev) => {
const next = new Set(prev);
next.delete(destinationId);
return next;
});
}
};
const handleDeleteCard = (destination: Destination) => {
setDeleteTarget(destination);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(
`/org/${orgId}/event-streaming-destination/${deleteTarget.destinationId}`
);
toast({ title: t("streamingDeletedSuccess") });
setDeleteDialogOpen(false);
setDeleteTarget(null);
loadDestinations();
} catch (e) {
toast({
variant: "destructive",
title: t("streamingFailedToDelete"),
description: formatAxiosError(e, t("streamingUnexpectedError"))
});
} finally {
setDeleting(false);
}
};
const openCreate = () => {
setTypePickerOpen(true);
};
const handleTypePicked = (_type: DestinationType) => {
setTypePickerOpen(false);
setEditingDestination(null);
setModalOpen(true);
};
const openEdit = (destination: Destination) => {
setEditingDestination(destination);
setModalOpen(true);
};
return (
<>
<SettingsSectionTitle
title={t("streamingTitle")}
description={t("streamingDescription")}
/>
<PaidFeaturesAlert tiers={tierMatrix[TierFeature.SIEM]} />
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-lg border bg-card p-5 min-h-36 animate-pulse"
/>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{destinations.map((dest) => (
<DestinationCard
key={dest.destinationId}
destination={dest}
onToggle={handleToggle}
onEdit={openEdit}
onDelete={handleDeleteCard}
isToggling={togglingIds.has(dest.destinationId)}
disabled={!isEnterprise}
/>
))}
{/* Add card is always clickable — paywall is enforced inside the picker */}
<AddDestinationCard onClick={openCreate} />
</div>
)}
<DestinationTypePicker
open={typePickerOpen}
onOpenChange={setTypePickerOpen}
onSelect={handleTypePicked}
isPaywalled={!isEnterprise}
/>
<HttpDestinationCredenza
open={modalOpen}
onOpenChange={setModalOpen}
editing={editingDestination}
orgId={orgId}
onSaved={loadDestinations}
/>
{deleteTarget && (
<ConfirmDeleteDialog
open={deleteDialogOpen}
setOpen={(v) => {
setDeleteDialogOpen(v);
if (!v) setDeleteTarget(null);
}}
string={
parseHttpConfig(deleteTarget.config).name ||
t("streamingDeleteDialogThisDestination")
}
title={t("streamingDeleteTitle")}
dialog={
<p>
{t("streamingDeleteDialogAreYouSure")}{" "}
<span>
{parseHttpConfig(deleteTarget.config).name ||
t("streamingDeleteDialogThisDestination")}
</span>
{t("streamingDeleteDialogPermanentlyRemoved")}
</p>
}
buttonText={t("streamingDeleteButtonText")}
onConfirm={handleDeleteConfirm}
/>
)}
</>
);
}

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Settings"
};
type OrgPageProps = {
params: Promise<{ orgId: string }>;
};

View File

@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SiteProvisioningKeysTable, {
SiteProvisioningKeyRow
} from "../../../../../components/SiteProvisioningKeysTable";
} from "@app/components/SiteProvisioningKeysTable";
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { getTranslations } from "next-intl/server";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -12,6 +12,11 @@ import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Provisioning Keys"
};
type ProvisioningKeysPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Provisioning"
};
type ProvisioningPageProps = {
params: Promise<{ orgId: string }>;
};
@@ -7,4 +12,4 @@ type ProvisioningPageProps = {
export default async function ProvisioningPage(props: ProvisioningPageProps) {
const params = await props.params;
redirect(`/${params.orgId}/settings/provisioning/keys`);
}
}

View File

@@ -9,6 +9,13 @@ import DismissableBanner from "@app/components/DismissableBanner";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import { ArrowRight, Plug } from "lucide-react";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Pending Sites"
};
type PendingSitesPageProps = {
params: Promise<{ orgId: string }>;
@@ -96,6 +103,10 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
</Button>
</Link>
</DismissableBanner>
<PaidFeaturesAlert
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
/>
<PendingSitesTable
sites={siteRows}
orgId={params.orgId}

View File

@@ -10,8 +10,13 @@ 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 type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Private Resources"
};
export interface ClientResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Public Resources"
};
export interface ResourcesPageProps {
params: Promise<{ orgId: string }>;
}

View File

@@ -133,8 +133,7 @@ export default function ResourceAuthenticationPage() {
...orgQueries.identityProviders({
orgId: org.org.orgId,
useOrgOnlyIdp: env.app.identityProviderMode === "org"
}),
enabled: isPaidUser(tierMatrix.orgOidc)
})
});
const pageLoading =

View File

@@ -14,6 +14,11 @@ import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "@app/components/ResourceInfoBox";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Public Resource"
};
export const dynamic = "force-dynamic";

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Public Resource"
};
export default async function ResourcePage(props: {
params: Promise<{ niceId: string; orgId: string }>;
}) {

View File

@@ -400,7 +400,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -424,7 +428,11 @@ function ProxyResourceTargetsForm({
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(row.original.targetId,
config.path === null && config.pathMatchType === null
? { ...config, rewritePath: null, rewritePathType: null }
: config
)
}
trigger={
<Button
@@ -670,6 +678,7 @@ function ProxyResourceTargetsForm({
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,
@@ -774,8 +783,12 @@ function ProxyResourceTargetsForm({
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
title: targets.length === 0
? t("targetTargetsCleared")
: t("settingsUpdated"),
description: targets.length === 0
? t("targetTargetsClearedDescription")
: t("settingsUpdatedDescription")
});
setTargetsToRemove([]);

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Public Resource"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -776,7 +776,17 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
trigger={
<Button
@@ -800,7 +810,17 @@ export default function Page() {
pathMatchType: row.original.pathMatchType
}}
onChange={(config) =>
updateTarget(row.original.targetId, config)
updateTarget(
row.original.targetId,
config.path === null &&
config.pathMatchType === null
? {
...config,
rewritePath: null,
rewritePathType: null
}
: config
)
}
trigger={
<Button
@@ -991,6 +1011,7 @@ export default function Page() {
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getRowId: (row) => String(row.targetId),
state: {
pagination: {
pageIndex: 0,
@@ -1052,7 +1073,7 @@ export default function Page() {
: null
);
}}
cols={2}
cols={3}
/>
</>
)}
@@ -1109,28 +1130,30 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<DomainPicker
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1
}
onDomainChange={(res) => {
if (!res) return;
<SettingsSectionForm>
<DomainPicker
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1
}
onDomainChange={(res) => {
if (!res) return;
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
console.log(
"Domain changed:",
res
);
}}
/>
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
console.log(
"Domain changed:",
res
);
}}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
) : (
@@ -1146,98 +1169,101 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...tcpUdpForm}>
<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="tcp-udp-settings-form"
>
<Controller
control={tcpUdpForm.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("protocol")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"protocolSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<SettingsSectionForm>
<Form {...tcpUdpForm}>
<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="tcp-udp-settings-form"
>
<Controller
control={
tcpUdpForm.control
}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"protocol"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"protocolSelect"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={tcpUdpForm.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}

View File

@@ -13,6 +13,11 @@ import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { toUnicode } from "punycode";
import { cache } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Public Resources"
};
export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>;
@@ -95,7 +100,8 @@ export default async function ProxyResourcesPage(
ip: target.ip,
port: target.port,
enabled: target.enabled,
healthStatus: target.healthStatus
healthStatus: target.healthStatus,
siteName: target.siteName
}))
};
});

View File

@@ -7,10 +7,13 @@ import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, {
ShareLinkRow
} from "../../../../components/ShareLinksTable";
import ShareLinksTable, { ShareLinkRow } from "@app/components/ShareLinksTable";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Shareable Links"
};
type ShareLinksPageProps = {
params: Promise<{ orgId: string }>;

View File

@@ -6,9 +6,13 @@ import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteInfoCard from "../../../../../components/SiteInfoCard";
import SiteInfoCard from "@app/components/SiteInfoCard";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Site"
};
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -1,5 +1,10 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Site"
};
export default async function SitePage(props: {
params: Promise<{ orgId: string; niceId: string }>;
}) {

View File

@@ -1,7 +1,5 @@
/*! SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
*/
// SPDX-License-Identifier: GPL-2.0
// Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
function gf(init: number[] | undefined = undefined) {
var r = new Float64Array(16);

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Create Site"
};
export default function Layout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -161,16 +161,13 @@ export default function Page() {
description: t("siteNewtTunnelDescription"),
disabled: true
},
...(env.flags.disableBasicWireguardSites
...(env.flags.disableBasicWireguardSites || build == "saas"
? []
: [
{
id: "wireguard" as SiteType,
title: t("siteWg"),
description:
build == "saas"
? t("siteWgDescriptionSaas")
: t("siteWgDescription"),
description: t("siteWgDescription"),
disabled: true
}
]),
@@ -426,9 +423,22 @@ export default function Page() {
}));
setRemoteExitNodeOptions(exitNodeOptions);
if (exitNodeOptions.length === 0) {
// No remote exit nodes available — remove local option and default to newt
setTunnelTypes((prev: any) =>
prev.filter((item: any) => item.id !== "local")
);
form.setValue("method", "newt");
}
}
} catch (error) {
console.error("Failed to fetch remote exit nodes:", error);
// If fetch fails, no remote exit nodes available — remove local option and default to newt
setTunnelTypes((prev: any) =>
prev.filter((item: any) => item.id !== "local")
);
form.setValue("method", "newt");
}
}

View File

@@ -5,8 +5,13 @@ import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "@app/components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesBanner from "@app/components/SitesBanner";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Sites"
};
type SitesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;