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

View File

@@ -9,6 +9,10 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import SetLastOrgCookie from "@app/components/SetLastOrgCookie";
import PrivateSubscriptionStatusProvider from "@app/providers/PrivateSubscriptionStatusProvider";
import { GetOrgSubscriptionResponse } from "@server/routers/private/billing/getOrgSubscription";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
export default async function OrgLayout(props: {
children: React.ReactNode;
@@ -17,6 +21,7 @@ export default async function OrgLayout(props: {
const cookie = await authCookieHeader();
const params = await props.params;
const orgId = params.orgId;
const env = pullEnv();
if (!orgId) {
redirect(`/`);
@@ -50,10 +55,31 @@ export default async function OrgLayout(props: {
redirect(`/`);
}
let subscriptionStatus = null;
if (build != "oss") {
try {
const getSubscription = cache(() =>
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
`/org/${orgId}/billing/subscription`,
cookie
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch (error) {
// If subscription fetch fails, keep subscriptionStatus as null
console.error("Failed to fetch subscription status:", error);
}
}
return (
<>
<PrivateSubscriptionStatusProvider
subscriptionStatus={subscriptionStatus}
env={env.app.environment}
sandbox_mode={env.app.sandbox_mode}
>
{props.children}
<SetLastOrgCookie orgId={orgId} />
</>
</PrivateSubscriptionStatusProvider>
);
}

View File

@@ -0,0 +1,97 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { verifySession } from "@app/lib/auth/verifySession";
import OrgProvider from "@app/providers/OrgProvider";
import OrgUserProvider from "@app/providers/OrgUserProvider";
import { GetOrgResponse } from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from 'next-intl/server';
type BillingSettingsProps = {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
};
export default async function BillingSettingsPage({
children,
params,
}: BillingSettingsProps) {
const { orgId } = await params;
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(`/`);
}
let orgUser = null;
try {
const getOrgUser = cache(async () =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
await authCookieHeader(),
),
);
const res = await getOrgUser();
orgUser = res.data.data;
} catch {
redirect(`/${orgId}`);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader(),
),
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${orgId}`);
}
const t = await getTranslations();
const navItems = [
{
title: t('billing'),
href: `/{orgId}/settings/billing`,
},
];
return (
<>
<OrgProvider org={org}>
<OrgUserProvider orgUser={orgUser}>
<SettingsSectionTitle
title={t('billing')}
description={t('orgBillingDescription')}
/>
{children}
</OrgUserProvider>
</OrgProvider>
</>
);
}

View File

@@ -0,0 +1,767 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { useState, useEffect } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import {
CreditCard,
Database,
Clock,
AlertCircle,
CheckCircle,
Users,
Calculator,
ExternalLink,
Gift,
Server
} from "lucide-react";
import { InfoPopup } from "@/components/ui/info-popup";
import {
GetOrgSubscriptionResponse,
GetOrgUsageResponse
} from "@server/routers/private/billing";
import { useTranslations } from "use-intl";
import Link from "next/link";
export default function GeneralPage() {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const t = useTranslations();
// Subscription state
const [subscription, setSubscription] =
useState<GetOrgSubscriptionResponse["subscription"]>(null);
const [subscriptionItems, setSubscriptionItems] = useState<
GetOrgSubscriptionResponse["items"]
>([]);
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
// Example usage data (replace with real usage data if available)
const [usageData, setUsageData] = useState<GetOrgUsageResponse["usage"]>(
[]
);
const [limitsData, setLimitsData] = useState<GetOrgUsageResponse["limits"]>(
[]
);
useEffect(() => {
async function fetchSubscription() {
setSubscriptionLoading(true);
try {
const res = await api.get<
AxiosResponse<GetOrgSubscriptionResponse>
>(`/org/${org.org.orgId}/billing/subscription`);
const { subscription, items } = res.data.data;
setSubscription(subscription);
setSubscriptionItems(items);
setHasSubscription(
!!subscription && subscription.status === "active"
);
} catch (error) {
toast({
title: t("billingFailedToLoadSubscription"),
description: formatAxiosError(error),
variant: "destructive"
});
} finally {
setSubscriptionLoading(false);
}
}
fetchSubscription();
}, [org.org.orgId]);
useEffect(() => {
async function fetchUsage() {
try {
const res = await api.get<AxiosResponse<GetOrgUsageResponse>>(
`/org/${org.org.orgId}/billing/usage`
);
const { usage, limits } = res.data.data;
setUsageData(usage);
setLimitsData(limits);
} catch (error) {
toast({
title: t("billingFailedToLoadUsage"),
description: formatAxiosError(error),
variant: "destructive"
});
} finally {
}
}
fetchUsage();
}, [org.org.orgId]);
const [hasSubscription, setHasSubscription] = useState(true);
const [isLoading, setIsLoading] = useState(false);
// const [newPricing, setNewPricing] = useState({
// pricePerGB: mockSubscription.pricePerGB,
// pricePerMinute: mockSubscription.pricePerMinute,
// })
const handleStartSubscription = async () => {
setIsLoading(true);
try {
const response = await api.post<AxiosResponse<string>>(
`/org/${org.org.orgId}/billing/create-checkout-session`,
{}
);
console.log("Checkout session response:", response.data);
const checkoutUrl = response.data.data;
if (checkoutUrl) {
window.location.href = checkoutUrl;
} else {
toast({
title: t("billingFailedToGetCheckoutUrl"),
description: t("billingPleaseTryAgainLater"),
variant: "destructive"
});
setIsLoading(false);
}
} catch (error) {
toast({
title: t("billingCheckoutError"),
description: formatAxiosError(error),
variant: "destructive"
});
setIsLoading(false);
}
};
const handleModifySubscription = async () => {
setIsLoading(true);
try {
const response = await api.post<AxiosResponse<string>>(
`/org/${org.org.orgId}/billing/create-portal-session`,
{}
);
const portalUrl = response.data.data;
if (portalUrl) {
window.location.href = portalUrl;
} else {
toast({
title: t("billingFailedToGetPortalUrl"),
description: t("billingPleaseTryAgainLater"),
variant: "destructive"
});
setIsLoading(false);
}
} catch (error) {
toast({
title: t("billingPortalError"),
description: formatAxiosError(error),
variant: "destructive"
});
setIsLoading(false);
}
};
// Usage IDs
const SITE_UPTIME = "siteUptime";
const USERS = "users";
const EGRESS_DATA_MB = "egressDataMb";
const DOMAINS = "domains";
const REMOTE_EXIT_NODES = "remoteExitNodes";
// Helper to calculate tiered price
function calculateTieredPrice(
usage: number,
tiersRaw: string | null | undefined
) {
if (!tiersRaw) return 0;
let tiers: any[] = [];
try {
tiers = JSON.parse(tiersRaw);
} catch {
return 0;
}
let total = 0;
let remaining = usage;
for (const tier of tiers) {
const upTo = tier.up_to === null ? Infinity : Number(tier.up_to);
const unitAmount =
tier.unit_amount !== null
? Number(tier.unit_amount / 100)
: tier.unit_amount_decimal
? Number(tier.unit_amount_decimal / 100)
: 0;
const tierQty = Math.min(
remaining,
upTo === Infinity ? remaining : upTo - (usage - remaining)
);
if (tierQty > 0) {
total += tierQty * unitAmount;
remaining -= tierQty;
}
if (remaining <= 0) break;
}
return total;
}
function getDisplayPrice(tiersRaw: string | null | undefined) {
//find the first non-zero tier price
if (!tiersRaw) return "$0.00";
let tiers: any[] = [];
try {
tiers = JSON.parse(tiersRaw);
} catch {
return "$0.00";
}
if (tiers.length === 0) return "$0.00";
// find the first tier with a non-zero price
const firstTier =
tiers.find(
(t) =>
t.unit_amount > 0 ||
(t.unit_amount_decimal && Number(t.unit_amount_decimal) > 0)
) || tiers[0];
const unitAmount =
firstTier.unit_amount !== null
? Number(firstTier.unit_amount / 100)
: firstTier.unit_amount_decimal
? Number(firstTier.unit_amount_decimal / 100)
: 0;
return `$${unitAmount.toFixed(4)}`; // ${firstTier.up_to === null ? "per unit" : `per ${firstTier.up_to} units`}`;
}
// Helper to get included usage amount from subscription tier
function getIncludedUsage(tiersRaw: string | null | undefined) {
if (!tiersRaw) return 0;
let tiers: any[] = [];
try {
tiers = JSON.parse(tiersRaw);
} catch {
return 0;
}
if (tiers.length === 0) return 0;
// Find the first tier (which represents included usage)
const firstTier = tiers[0];
if (!firstTier) return 0;
// If the first tier has a unit_amount of 0, it represents included usage
const isIncludedTier =
(firstTier.unit_amount === 0 || firstTier.unit_amount === null) &&
(!firstTier.unit_amount_decimal ||
Number(firstTier.unit_amount_decimal) === 0);
if (isIncludedTier && firstTier.up_to !== null) {
return Number(firstTier.up_to);
}
return 0;
}
// Helper to get display value for included usage
function getIncludedUsageDisplay(includedAmount: number, usageType: any) {
if (includedAmount === 0) return "0";
if (usageType.id === EGRESS_DATA_MB) {
// Convert MB to GB for data usage
return (includedAmount / 1000).toFixed(2);
}
if (usageType.id === USERS || usageType.id === DOMAINS) {
// divide by 32 days
return (includedAmount / 32).toFixed(2);
}
return includedAmount.toString();
}
// Helper to get usage, subscription item, and limit by usageId
function getUsageItemAndLimit(
usageData: any[],
subscriptionItems: any[],
limitsData: any[],
usageId: string
) {
const usage = usageData.find((u) => u.featureId === usageId);
if (!usage) return { usage: 0, item: undefined, limit: undefined };
const item = subscriptionItems.find((i) => i.meterId === usage.meterId);
const limit = limitsData.find((l) => l.featureId === usageId);
return { usage: usage ?? 0, item, limit };
}
// Helper to check if usage exceeds limit
function isOverLimit(usage: any, limit: any, usageType: any) {
if (!limit || !usage) return false;
const currentUsage = usageType.getLimitUsage(usage);
return currentUsage > limit.value;
}
// Map usage and pricing for each usage type
const usageTypes = [
{
id: EGRESS_DATA_MB,
label: t("billingDataUsage"),
icon: <Database className="h-4 w-4 text-blue-500" />,
unit: "GB",
unitRaw: "MB",
info: t("billingDataUsageInfo"),
note: "Not counted on self-hosted nodes",
// Convert MB to GB for display and pricing
getDisplay: (v: any) => (v.latestValue / 1000).toFixed(2),
getLimitDisplay: (v: any) => (v.value / 1000).toFixed(2),
getUsage: (v: any) => v.latestValue,
getLimitUsage: (v: any) => v.latestValue
},
{
id: SITE_UPTIME,
label: t("billingOnlineTime"),
icon: <Clock className="h-4 w-4 text-green-500" />,
unit: "min",
info: t("billingOnlineTimeInfo"),
note: "Not counted on self-hosted nodes",
getDisplay: (v: any) => v.latestValue,
getLimitDisplay: (v: any) => v.value,
getUsage: (v: any) => v.latestValue,
getLimitUsage: (v: any) => v.latestValue
},
{
id: USERS,
label: t("billingUsers"),
icon: <Users className="h-4 w-4 text-purple-500" />,
unit: "",
unitRaw: "user days",
info: t("billingUsersInfo"),
getDisplay: (v: any) => v.instantaneousValue,
getLimitDisplay: (v: any) => v.value,
getUsage: (v: any) => v.latestValue,
getLimitUsage: (v: any) => v.instantaneousValue
},
{
id: DOMAINS,
label: t("billingDomains"),
icon: <CreditCard className="h-4 w-4 text-yellow-500" />,
unit: "",
unitRaw: "domain days",
info: t("billingDomainInfo"),
getDisplay: (v: any) => v.instantaneousValue,
getLimitDisplay: (v: any) => v.value,
getUsage: (v: any) => v.latestValue,
getLimitUsage: (v: any) => v.instantaneousValue
},
{
id: REMOTE_EXIT_NODES,
label: t("billingRemoteExitNodes"),
icon: <Server className="h-4 w-4 text-red-500" />,
unit: "",
unitRaw: "node days",
info: t("billingRemoteExitNodesInfo"),
getDisplay: (v: any) => v.instantaneousValue,
getLimitDisplay: (v: any) => v.value,
getUsage: (v: any) => v.latestValue,
getLimitUsage: (v: any) => v.instantaneousValue
}
];
if (subscriptionLoading) {
return (
<div className="flex justify-center items-center h-64">
<span>{t("billingLoadingSubscription")}</span>
</div>
);
}
return (
<SettingsContainer>
<div className="flex items-center justify-between mb-6">
<Badge
variant={
subscription?.status === "active" ? "green" : "outline"
}
>
{subscription?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" />
)}
{subscription
? subscription.status.charAt(0).toUpperCase() +
subscription.status.slice(1)
: t("billingFreeTier")}
</Badge>
<Link
className="flex items-center gap-2 text-primary hover:underline"
href="https://digpangolin.com/pricing"
target="_blank"
rel="noopener noreferrer"
>
<span>{t("billingPricingCalculatorLink")}</span>
<ExternalLink className="h-4 w-4" />
</Link>
</div>
{usageTypes.some((type) => {
const { usage, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
limitsData,
type.id
);
return isOverLimit(usage, limit, type);
}) && (
<Alert className="border-destructive/50 bg-destructive/10 mb-6">
<AlertCircle className="h-4 w-4 text-destructive" />
<AlertDescription className="text-destructive">
{t("billingWarningOverLimit")}
</AlertDescription>
</Alert>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("billingUsageLimitsOverview")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("billingMonitorUsage")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-4">
{usageTypes.map((type) => {
const { usage, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
limitsData,
type.id
);
const displayUsage = type.getDisplay(usage);
const usageForPricing = type.getLimitUsage(usage);
const overLimit = isOverLimit(usage, limit, type);
const percentage = limit
? Math.min(
(usageForPricing / limit.value) * 100,
100
)
: 0;
return (
<div key={type.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{type.icon}
<span className="font-medium">
{type.label}
</span>
<InfoPopup info={type.info} />
</div>
<div className="text-right">
<span
className={`font-bold ${overLimit ? "text-red-600" : ""}`}
>
{displayUsage} {type.unit}
</span>
{limit && (
<span className="text-muted-foreground">
{" "}
/{" "}
{type.getLimitDisplay(
limit
)}{" "}
{type.unit}
</span>
)}
</div>
</div>
{type.note && (
<div className="text-xs text-muted-foreground mt-1">
{type.note}
</div>
)}
{limit && (
<Progress
value={Math.min(percentage, 100)}
variant={
overLimit
? "danger"
: percentage > 80
? "warning"
: "success"
}
/>
)}
{!limit && (
<p className="text-sm text-muted-foreground">
{t("billingNoLimitConfigured")}
</p>
)}
</div>
);
})}
</div>
</SettingsSectionBody>
</SettingsSection>
{(hasSubscription ||
(!hasSubscription && limitsData.length > 0)) && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("billingIncludedUsage")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{hasSubscription
? t("billingIncludedUsageDescription")
: t("billingFreeTierIncludedUsage")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="grid gap-4 md:grid-cols-2">
{usageTypes.map((type) => {
const { item, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
limitsData,
type.id
);
// For subscribed users, show included usage from tiers
// For free users, show the limit as "included"
let includedAmount = 0;
let displayIncluded = "0";
if (hasSubscription && item) {
includedAmount = getIncludedUsage(
item.tiers
);
displayIncluded = getIncludedUsageDisplay(
includedAmount,
type
);
} else if (
!hasSubscription &&
limit &&
limit.value > 0
) {
// Show free tier limits as "included"
includedAmount = limit.value;
displayIncluded =
type.getLimitDisplay(limit);
}
if (includedAmount === 0) return null;
return (
<div
key={type.id}
className="flex items-center justify-between p-3 border rounded-lg bg-muted/30"
>
<div className="flex items-center gap-2">
{type.icon}
<span className="font-medium">
{type.label}
</span>
</div>
<div className="text-right">
<div className="flex items-center gap-1 justify-end">
{hasSubscription ? (
<CheckCircle className="h-3 w-3 text-green-600" />
) : (
<Gift className="h-3 w-3 text-blue-600" />
)}
<span
className={`font-semibold ${hasSubscription ? "text-green-600" : "text-blue-600"}`}
>
{displayIncluded}{" "}
{type.unit}
</span>
</div>
<div className="text-xs text-muted-foreground">
{hasSubscription
? t("billingIncluded")
: t("billingFreeTier")}
</div>
</div>
</div>
);
})}
</div>
</SettingsSectionBody>
</SettingsSection>
)}
{hasSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("billingEstimatedPeriod")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
{usageTypes.map((type) => {
const { usage, item } =
getUsageItemAndLimit(
usageData,
subscriptionItems,
limitsData,
type.id
);
const displayPrice = getDisplayPrice(
item?.tiers
);
return (
<div
className="flex justify-between"
key={type.id}
>
<span>{type.label}:</span>
<span>
{type.getUsage(usage)}{" "}
{type.unitRaw || type.unit} x{" "}
{displayPrice}
</span>
</div>
);
})}
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
{subscriptionItems
.filter(
(item) =>
item.unitAmount &&
item.unitAmount > 0 &&
!item.tiers &&
!item.meterId
)
.map((item, index) => (
<div
className="flex justify-between"
key={`recurring-${item.subscriptionItemId || index}`}
>
<span>
{item.name ||
t("billingRecurringCharge")}
:
</span>
<span>
$
{(
(item.unitAmount || 0) / 100
).toFixed(2)}
</span>
</div>
))}
<Separator />
<div className="flex justify-between font-semibold">
<span>{t("billingEstimatedTotal")}</span>
<span>
$
{(
usageTypes.reduce((sum, type) => {
const { usage, item } =
getUsageItemAndLimit(
usageData,
subscriptionItems,
limitsData,
type.id
);
const usageForPricing =
type.getUsage(usage);
const cost = item
? calculateTieredPrice(
usageForPricing,
item.tiers
)
: 0;
return sum + cost;
}, 0) +
// Add recurring charges
subscriptionItems
.filter(
(item) =>
item.unitAmount &&
item.unitAmount > 0 &&
!item.tiers &&
!item.meterId
)
.reduce(
(sum, item) =>
sum +
(item.unitAmount || 0) /
100,
0
)
).toFixed(2)}
</span>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium">
{t("billingNotes")}
</h4>
<div className="text-sm text-muted-foreground space-y-1">
<p>{t("billingEstimateNote")}</p>
<p>{t("billingActualChargesMayVary")}</p>
<p>{t("billingBilledAtEnd")}</p>
</div>
</div>
</div>
<SettingsSectionFooter>
<Button
variant="secondary"
onClick={() => handleModifySubscription()}
disabled={isLoading}
>
{t("billingModifySubscription")}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
)}
{!hasSubscription && (
<SettingsSection>
<SettingsSectionBody>
<div className="text-center py-8">
<CreditCard className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{t("billingNoActiveSubscription")}
</p>
<Button
onClick={() => handleStartSubscription()}
disabled={isLoading}
>
{t("billingStartSubscription")}
</Button>
</div>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -0,0 +1,996 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
import { useRouter, useParams, redirect } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter,
SettingsSectionGrid
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
export default function GeneralPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const { idpId, orgId } = useParams();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState<
"role" | "expression"
>("role");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const { isUnlocked } = useLicenseStatusContext();
const [redirectUrl, setRedirectUrl] = useState(
`${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`
);
const t = useTranslations();
// OIDC form schema (full configuration)
const OidcFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(),
authUrl: z.string().url({ message: t("idpErrorAuthUrlInvalid") }),
tokenUrl: z.string().url({ message: t("idpErrorTokenUrlInvalid") }),
identifierPath: z.string().min(1, { message: t("idpPathRequired") }),
emailPath: z.string().nullable().optional(),
namePath: z.string().nullable().optional(),
scopes: z.string().min(1, { message: t("idpScopeRequired") }),
autoProvision: z.boolean().default(false)
});
// Google form schema (simplified)
const GoogleFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false)
});
// Azure form schema (simplified with tenant ID)
const AzureFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false)
});
type OidcFormValues = z.infer<typeof OidcFormSchema>;
type GoogleFormValues = z.infer<typeof GoogleFormSchema>;
type AzureFormValues = z.infer<typeof AzureFormSchema>;
type GeneralFormValues =
| OidcFormValues
| GoogleFormValues
| AzureFormValues;
// Get the appropriate schema based on variant
const getFormSchema = () => {
switch (variant) {
case "google":
return GoogleFormSchema;
case "azure":
return AzureFormSchema;
default:
return OidcFormSchema;
}
};
const form = useForm<GeneralFormValues>({
resolver: zodResolver(getFormSchema()) as any, // is this right?
defaultValues: {
name: "",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
identifierPath: "sub",
emailPath: "email",
namePath: "name",
scopes: "openid profile email",
autoProvision: true,
roleMapping: null,
roleId: null,
tenantId: ""
}
});
// Update form resolver when variant changes
useEffect(() => {
form.clearErrors();
// Note: We can't change the resolver dynamically, so we'll handle validation in onSubmit
}, [variant]);
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
const loadIdp = async (
availableRoles: { roleId: number; name: string }[]
) => {
try {
const res = await api.get(`/org/${orgId}/idp/${idpId}`);
if (res.status === 200) {
const data = res.data.data;
const roleMapping = data.idpOrg.roleMapping;
const idpVariant = data.idpOidcConfig?.variant || "oidc";
setRedirectUrl(res.data.data.redirectUrl);
// Set the variant
setVariant(idpVariant as "oidc" | "google" | "azure");
// Check if roleMapping matches the basic pattern '{role name}' (simple single role)
// This should NOT match complex expressions like 'Admin' || 'Member'
const isBasicRolePattern =
roleMapping &&
typeof roleMapping === "string" &&
/^'[^']+'$/.test(roleMapping);
// Determine if roleMapping is a number (roleId) or matches basic pattern
const isRoleId =
!isNaN(Number(roleMapping)) && roleMapping !== "";
const isRoleName = isBasicRolePattern;
// Extract role name from basic pattern for matching
let extractedRoleName = null;
if (isRoleName) {
extractedRoleName = roleMapping.slice(1, -1); // Remove quotes
}
// Try to find matching role by name if we have a basic pattern
let matchingRoleId = undefined;
if (extractedRoleName && availableRoles.length > 0) {
const matchingRole = availableRoles.find(
(role) => role.name === extractedRoleName
);
if (matchingRole) {
matchingRoleId = matchingRole.roleId;
}
}
// Extract tenant ID from Azure URLs if present
let tenantId = "";
if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) {
// Azure URL format: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize
console.log(
"Azure authUrl:",
data.idpOidcConfig.authUrl
);
const tenantMatch = data.idpOidcConfig.authUrl.match(
/login\.microsoftonline\.com\/([^\/]+)\/oauth2/
);
console.log("Tenant match:", tenantMatch);
if (tenantMatch) {
tenantId = tenantMatch[1];
console.log("Extracted tenantId:", tenantId);
}
}
// Reset form with appropriate data based on variant
const formData: any = {
name: data.idp.name,
clientId: data.idpOidcConfig.clientId,
clientSecret: data.idpOidcConfig.clientSecret,
autoProvision: data.idp.autoProvision,
roleMapping: roleMapping || null,
roleId: isRoleId
? Number(roleMapping)
: matchingRoleId || null
};
console.log(formData);
// Add variant-specific fields
if (idpVariant === "oidc") {
formData.authUrl = data.idpOidcConfig.authUrl;
formData.tokenUrl = data.idpOidcConfig.tokenUrl;
formData.identifierPath =
data.idpOidcConfig.identifierPath;
formData.emailPath =
data.idpOidcConfig.emailPath || null;
formData.namePath = data.idpOidcConfig.namePath || null;
formData.scopes = data.idpOidcConfig.scopes;
} else if (idpVariant === "azure") {
formData.tenantId = tenantId;
console.log("Setting tenantId in formData:", tenantId);
}
form.reset(formData);
// Set the role mapping mode based on the data
// Default to "expression" unless it's a simple roleId or basic '{role name}' pattern
setRoleMappingMode(
isRoleId || isRoleName ? "role" : "expression"
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
router.push(`/${orgId}/settings/idp`);
} finally {
setInitialLoading(false);
}
};
const loadData = async () => {
const rolesRes = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
return null;
});
const availableRoles =
rolesRes?.status === 200 ? rolesRes.data.data.roles : [];
setRoles(availableRoles);
await loadIdp(availableRoles);
};
loadData();
}, []);
async function onSubmit(data: GeneralFormValues) {
setLoading(true);
try {
// Validate against the correct schema based on variant
const schema = getFormSchema();
const validationResult = schema.safeParse(data);
if (!validationResult.success) {
// Set form errors
const errors = validationResult.error.flatten().fieldErrors;
Object.keys(errors).forEach((key) => {
const fieldName = key as keyof GeneralFormValues;
const errorMessage =
(errors as any)[key]?.[0] || t("invalidValue");
form.setError(fieldName, {
type: "manual",
message: errorMessage
});
});
setLoading(false);
return;
}
const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
// Build payload based on variant
let payload: any = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
autoProvision: data.autoProvision,
roleMapping:
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || ""
};
// Add variant-specific fields
if (variant === "oidc") {
const oidcData = data as OidcFormValues;
payload = {
...payload,
authUrl: oidcData.authUrl,
tokenUrl: oidcData.tokenUrl,
identifierPath: oidcData.identifierPath,
emailPath: oidcData.emailPath || "",
namePath: oidcData.namePath || "",
scopes: oidcData.scopes
};
} else if (variant === "azure") {
const azureData = data as AzureFormValues;
// Construct URLs dynamically for Azure provider
const authUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/token`;
payload = {
...payload,
authUrl: authUrl,
tokenUrl: tokenUrl,
identifierPath: "email",
emailPath: "email",
namePath: "name",
scopes: "openid profile email"
};
} else if (variant === "google") {
// Google uses predefined URLs
payload = {
...payload,
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
identifierPath: "email",
emailPath: "email",
namePath: "name",
scopes: "openid profile email"
};
}
const res = await api.post(
`/org/${orgId}/idp/${idpId}/oidc`,
payload
);
if (res.status === 200) {
toast({
title: t("success"),
description: t("idpUpdatedDescription")
});
router.refresh();
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
if (initialLoading) {
return null;
}
return (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
{t("redirectUrl")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("redirectUrlAbout")}
</AlertTitle>
<AlertDescription>
{t("redirectUrlAboutDescription")}
</AlertDescription>
</Alert>
{/* IDP Type Indicator */}
<div className="flex items-center space-x-2 mb-4">
<span className="text-sm font-medium text-muted-foreground">
{t("idpTypeLabel")}:
</span>
<IdpTypeBadge type={variant} />
</div>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("idpDisplayName")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Auto Provision Settings */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpAutoProvisionUsers")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpAutoProvisionUsersDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<AutoProvisionConfigWidget
control={form.control}
autoProvision={form.watch(
"autoProvision"
)}
onAutoProvisionChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);
// Clear roleId and roleMapping when mode changes
form.setValue("roleId", null);
form.setValue("roleMapping", null);
}}
roles={roles}
roleIdFieldName="roleId"
roleMappingFieldName="roleMapping"
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Google Configuration */}
{variant === "google" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpGoogleConfiguration")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpGoogleConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpGoogleClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpGoogleClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* Azure Configuration */}
{variant === "azure" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpAzureConfiguration")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpAzureConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="tenantId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTenantId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAzureTenantIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAzureClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAzureClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* OIDC Configuration */}
{variant === "oidc" && (
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpOidcConfigure")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpOidcConfigureDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(
onSubmit
)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpClientSecret"
)}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpToken")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTokenDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(
onSubmit
)}
className="space-y-4"
id="general-settings-form"
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpJmespathAbout")}
</AlertTitle>
<AlertDescription>
{t(
"idpJmespathAboutDescription"
)}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t(
"idpJmespathAboutDescriptionLink"
)}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathLabel"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
value={
field.value ||
""
}
/>
</FormControl>
<FormDescription>
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
value={
field.value ||
""
}
/>
</FormControl>
<FormDescription>
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
</SettingsContainer>
<div className="flex justify-end mt-8">
<Button
type="button"
form="general-settings-form"
loading={loading}
disabled={loading}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("saveGeneralSettings")}
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,63 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { internal } from "@app/lib/api";
import { GetIdpResponse as GetOrgIdpResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
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 { getTranslations } from "next-intl/server";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ orgId: string; idpId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
const t = await getTranslations();
let idp = null;
try {
const res = await internal.get<AxiosResponse<GetOrgIdpResponse>>(
`/org/${params.orgId}/idp/${params.idpId}`,
await authCookieHeader()
);
idp = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/idp`);
}
const navItems: HorizontalTabs = [
{
title: t("general"),
href: `/${params.orgId}/settings/idp/${params.idpId}/general`
}
];
return (
<>
<SettingsSectionTitle
title={t("idpSettings", { idpName: idp.idp.name })}
description={t("idpSettingsDescription")}
/>
<div className="space-y-6">
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</>
);
}

View File

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

View File

@@ -0,0 +1,870 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionGrid,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { createElement, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { 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 { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
import Image from "next/image";
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role";
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState<
"role" | "expression"
>("role");
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const params = useParams();
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
type: z.enum(["oidc", "google", "azure"]),
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
clientSecret: z
.string()
.min(1, { message: t("idpClientSecretRequired") }),
authUrl: z
.string()
.url({ message: t("idpErrorAuthUrlInvalid") })
.optional(),
tokenUrl: z
.string()
.url({ message: t("idpErrorTokenUrlInvalid") })
.optional(),
identifierPath: z
.string()
.min(1, { message: t("idpPathRequired") })
.optional(),
emailPath: z.string().optional(),
namePath: z.string().optional(),
scopes: z
.string()
.min(1, { message: t("idpScopeRequired") })
.optional(),
tenantId: z.string().optional(),
autoProvision: z.boolean().default(false),
roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional()
});
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
interface ProviderTypeOption {
id: "oidc" | "google" | "azure";
title: string;
description: string;
icon?: React.ReactNode;
}
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: t("idpOidcDescription")
},
{
id: "google",
title: t("idpGoogleTitle"),
description: t("idpGoogleDescription"),
icon: (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
)
},
{
id: "azure",
title: t("idpAzureTitle"),
description: t("idpAzureDescription"),
icon: (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
)
}
];
const form = useForm({
resolver: zodResolver(createIdpFormSchema),
defaultValues: {
name: "",
type: "oidc",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
identifierPath: "sub",
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
tenantId: "",
autoProvision: false,
roleMapping: null,
roleId: null
}
});
// Fetch roles on component mount
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${params.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: t("accessRoleErrorFetch"),
description: formatAxiosError(
e,
t("accessRoleErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
fetchRoles();
}, []);
// Handle provider type changes and set defaults
const handleProviderChange = (value: "oidc" | "google" | "azure") => {
form.setValue("type", value);
if (value === "google") {
// Set Google defaults
form.setValue(
"authUrl",
"https://accounts.google.com/o/oauth2/v2/auth"
);
form.setValue("tokenUrl", "https://oauth2.googleapis.com/token");
form.setValue("identifierPath", "email");
form.setValue("emailPath", "email");
form.setValue("namePath", "name");
form.setValue("scopes", "openid profile email");
} else if (value === "azure") {
// Set Azure Entra ID defaults (URLs will be constructed dynamically)
form.setValue(
"authUrl",
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize"
);
form.setValue(
"tokenUrl",
"https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token"
);
form.setValue("identifierPath", "email");
form.setValue("emailPath", "email");
form.setValue("namePath", "name");
form.setValue("scopes", "openid profile email");
form.setValue("tenantId", "");
} else {
// Reset to OIDC defaults
form.setValue("authUrl", "");
form.setValue("tokenUrl", "");
form.setValue("identifierPath", "sub");
form.setValue("namePath", "name");
form.setValue("emailPath", "email");
form.setValue("scopes", "openid profile email");
}
};
async function onSubmit(data: CreateIdpFormValues) {
setCreateLoading(true);
try {
// Construct URLs dynamically for Azure provider
let authUrl = data.authUrl;
let tokenUrl = data.tokenUrl;
if (data.type === "azure" && data.tenantId) {
authUrl = authUrl?.replace("{{TENANT_ID}}", data.tenantId);
tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId);
}
const roleName = roles.find((r) => r.roleId === data.roleId)?.name;
const payload = {
name: data.name,
clientId: data.clientId,
clientSecret: data.clientSecret,
authUrl: authUrl,
tokenUrl: tokenUrl,
identifierPath: data.identifierPath,
emailPath: data.emailPath,
namePath: data.namePath,
autoProvision: data.autoProvision,
roleMapping:
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || "",
scopes: data.scopes,
variant: data.type
};
// Use the appropriate endpoint based on provider type
const endpoint = "oidc";
const res = await api.put(
`/org/${params.orgId}/idp/${endpoint}`,
payload
);
if (res.status === 201) {
toast({
title: t("success"),
description: t("idpCreatedDescription")
});
router.push(
`/${params.orgId}/settings/idp/${res.data.data.idpId}`
);
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setCreateLoading(false);
}
}
return (
<>
<div className="flex justify-between">
<HeaderTitle
title={t("idpCreate")}
description={t("idpCreateDescription")}
/>
<Button
variant="outline"
onClick={() => {
router.push("/admin/idp");
}}
>
{t("idpSeeAll")}
</Button>
</div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpCreateSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t("idpDisplayName")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTypeDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
{/* Auto Provision Settings */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpAutoProvisionUsers")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpAutoProvisionUsersDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<AutoProvisionConfigWidget
control={form.control}
autoProvision={form.watch(
"autoProvision"
) as boolean} // is this right?
onAutoProvisionChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
roleMappingMode={roleMappingMode}
onRoleMappingModeChange={(data) => {
setRoleMappingMode(data);
// Clear roleId and roleMapping when mode changes
form.setValue("roleId", null);
form.setValue("roleMapping", null);
}}
roles={roles}
roleIdFieldName="roleId"
roleMappingFieldName="roleMapping"
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{form.watch("type") === "google" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpGoogleConfigurationTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpGoogleConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpGoogleClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpGoogleClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{form.watch("type") === "azure" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpAzureConfigurationTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpAzureConfigurationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="tenantId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTenantIdLabel")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAzureTenantIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpAzureClientIdDescription2"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAzureClientSecretDescription2"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{form.watch("type") === "oidc" && (
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpOidcConfigure")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpOidcConfigureDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientId")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpClientIdDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpClientSecret")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpClientSecretDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpAuthUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/authorize"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpAuthUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpTokenUrl")}
</FormLabel>
<FormControl>
<Input
placeholder="https://your-idp.com/oauth2/token"
{...field}
/>
</FormControl>
<FormDescription>
{t(
"idpTokenUrlDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpOidcConfigureAlert")}
</AlertTitle>
<AlertDescription>
{t("idpOidcConfigureAlertDescription")}
</AlertDescription>
</Alert>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("idpToken")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("idpTokenDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpJmespathAbout")}
</AlertTitle>
<AlertDescription>
{t(
"idpJmespathAboutDescription"
)}{" "}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t(
"idpJmespathAboutDescriptionLink"
)}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("idpJmespathLabel")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathLabelDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathEmailPathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathEmailPathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpJmespathNamePathOptional"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpJmespathNamePathOptionalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"idpOidcConfigureScopes"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"idpOidcConfigureScopesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${params.orgId}/settings/idp`);
}}
>
{t("cancel")}
</Button>
<Button
type="submit"
disabled={createLoading}
loading={createLoading}
onClick={() => {
// log any issues with the form
console.log(form.formState.errors);
form.handleSubmit(onSubmit)();
}}
>
{t("idpSubmit")}
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,81 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { internal, priv } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable";
import { getTranslations } from "next-intl/server";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { cache } from "react";
import {
GetOrgSubscriptionResponse,
GetOrgTierResponse
} from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
import { build } from "@server/build";
type OrgIdpPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function OrgIdpPage(props: OrgIdpPageProps) {
const params = await props.params;
let idps: IdpRow[] = [];
try {
const res = await internal.get<AxiosResponse<{ idps: IdpRow[] }>>(
`/org/${params.orgId}/idp`,
await authCookieHeader()
);
idps = res.data.data.idps;
} catch (e) {
console.error(e);
}
const t = await getTranslations();
let subscriptionStatus: GetOrgTierResponse | null = null;
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
`/org/${params.orgId}/billing/tier`
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
return (
<>
<SettingsSectionTitle
title={t("idpManage")}
description={t("idpManageDescription")}
/>
{build === "saas" && !subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("idpDisabled")} {t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<IdpTable idps={idps} orgId={params.orgId} />
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,8 @@ import { ListIdpsResponse } from "@server/routers/idp";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import Image from "next/image";
import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext";
import { TierId } from "@server/lib/private/billing/tiers";
type UserType = "internal" | "oidc";
@@ -74,6 +76,9 @@ export default function Page() {
const api = createApiClient({ env });
const t = useTranslations();
const subscription = usePrivateSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
const [selectedOption, setSelectedOption] = useState<string | null>("internal");
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -227,8 +232,14 @@ export default function Page() {
}
async function fetchIdps() {
if (build === "saas" && !subscribed) {
return;
}
const res = await api
.get<AxiosResponse<ListIdpsResponse>>("/idp")
.get<
AxiosResponse<ListIdpsResponse>
>(build === "saas" ? `/org/${orgId}/idp` : "/idp")
.catch((e) => {
console.error(e);
toast({
@@ -430,7 +441,7 @@ export default function Page() {
<div>
<SettingsContainer>
{!inviteLink && build !== "saas" && dataLoaded ? (
{!inviteLink ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -1,10 +1,12 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import AuthPageSettings, { AuthPageSettingsRef } from "@app/components/private/AuthPageSettings";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { toast } from "@app/hooks/useToast";
import { useState } from "react";
import { useState, useRef } from "react";
import {
Form,
FormControl,
@@ -15,6 +17,7 @@ import {
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -38,7 +41,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
// Updated schema to include subnet field
// Schema for general organization settings
const GeneralFormSchema = z.object({
name: z.string(),
subnet: z.string().optional()
@@ -58,6 +61,7 @@ export default function GeneralPage() {
const [loadingDelete, setLoadingDelete] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
@@ -121,28 +125,33 @@ export default function GeneralPage() {
async function onSubmit(data: GeneralFormValues) {
setLoadingSave(true);
await api
.post(`/org/${org?.org.orgId}`, {
name: data.name,
try {
// Update organization
await api.post(`/org/${org?.org.orgId}`, {
name: data.name
// subnet: data.subnet // Include subnet in the API request
})
.then(() => {
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
})
.catch((e) => {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
})
.finally(() => {
setLoadingSave(false);
});
// Also save auth page settings if they have unsaved changes
if (build === "saas" && authPageSettingsRef.current?.hasUnsavedChanges()) {
await authPageSettingsRef.current.saveAuthSettings();
}
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("orgErrorUpdate"),
description: formatAxiosError(e, t("orgErrorUpdateMessage"))
});
} finally {
setLoadingSave(false);
}
}
return (
@@ -207,7 +216,9 @@ export default function GeneralPage() {
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>Subnet</FormLabel>
<FormLabel>
{t("subnet")}
</FormLabel>
<FormControl>
<Input
{...field}
@@ -216,9 +227,7 @@ export default function GeneralPage() {
</FormControl>
<FormMessage />
<FormDescription>
The subnet for this
organization's network
configuration.
{t("subnetDescription")}
</FormDescription>
</FormItem>
)}
@@ -228,18 +237,23 @@ export default function GeneralPage() {
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="org-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveGeneralSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
{build === "oss" && (
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
{/* Save Button */}
<div className="flex justify-end">
<Button
type="submit"
form="org-settings-form"
loading={loadingSave}
disabled={loadingSave}
>
{t("saveGeneralSettings")}
</Button>
</div>
{build !== "saas" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -262,6 +276,7 @@ export default function GeneralPage() {
</SettingsSectionFooter>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -27,7 +27,7 @@ import { orgNavSections } from "@app/app/navigation";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: `Settings - Pangolin`,
title: `Settings - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
description: ""
};

View File

@@ -58,6 +58,9 @@ import {
SelectValue
} from "@app/components/ui/select";
import { Separator } from "@app/components/ui/separator";
import { build } from "@server/build";
import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext";
import { TierId } from "@server/lib/private/billing/tiers";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -94,6 +97,9 @@ export default function ResourceAuthenticationPage() {
const router = useRouter();
const t = useTranslations();
const subscription = usePrivateSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
const [pageLoading, setPageLoading] = useState(true);
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
@@ -178,7 +184,7 @@ export default function ResourceAuthenticationPage() {
AxiosResponse<{
idps: { idpId: number; name: string }[];
}>
>("/idp")
>(build === "saas" ? `/org/${org?.org.orgId}/idp` : "/idp")
]);
setAllRoles(
@@ -223,12 +229,23 @@ export default function ResourceAuthenticationPage() {
}))
);
setAllIdps(
idpsResponse.data.data.idps.map((idp) => ({
id: idp.idpId,
text: idp.name
}))
);
if (build === "saas") {
if (subscribed) {
setAllIdps(
idpsResponse.data.data.idps.map((idp) => ({
id: idp.idpId,
text: idp.name
}))
);
}
} else {
setAllIdps(
idpsResponse.data.data.idps.map((idp) => ({
id: idp.idpId,
text: idp.name
}))
);
}
if (
autoLoginEnabled &&

View File

@@ -79,6 +79,7 @@ import {
import { ContainersSelector } from "@app/components/ContainersSelector";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import HealthCheckDialog from "@/components/HealthCheckDialog";
import { DockerManager, DockerState } from "@app/lib/docker";
import { Container } from "@server/routers/site";
import {
@@ -98,50 +99,64 @@ import {
} from "@app/components/ui/command";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { HeadersInput } from "@app/components/HeadersInput";
import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge";
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
siteId: z.number().int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable()
}).refine(
(data) => {
// If path is provided, pathMatchType must be provided
if (data.path && !data.pathMatchType) {
return false;
}
// If pathMatchType is provided, path must be provided
if (data.pathMatchType && !data.path) {
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
const addTargetSchema = z
.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
siteId: z.number().int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable()
})
.refine(
(data) => {
// If path is provided, pathMatchType must be provided
if (data.path && !data.pathMatchType) {
return false;
}
// If pathMatchType is provided, path must be provided
if (data.pathMatchType && !data.path) {
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
message: "Invalid path configuration"
}
return true;
},
{
message: "Invalid path configuration"
}
)
)
.refine(
(data) => {
// If rewritePath is provided, rewritePathType must be provided
@@ -229,6 +244,10 @@ export default function ReverseProxyTargets(props: {
const [proxySettingsLoading, setProxySettingsLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
const router = useRouter();
const proxySettingsSchema = z.object({
@@ -246,7 +265,9 @@ export default function ReverseProxyTargets(props: {
message: t("proxyErrorInvalidHeader")
}
),
headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable()
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
});
const tlsSettingsSchema = z.object({
@@ -280,7 +301,7 @@ export default function ReverseProxyTargets(props: {
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
rewritePathType: null
} as z.infer<typeof addTargetSchema>
});
@@ -463,7 +484,21 @@ export default function ReverseProxyTargets(props: {
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId
resourceId: resource.resourceId,
hcEnabled: false,
hcPath: null,
hcMethod: null,
hcInterval: null,
hcTimeout: null,
hcHeaders: null,
hcScheme: null,
hcHostname: null,
hcPort: null,
hcFollowRedirects: null,
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null
};
setTargets([...targets, newTarget]);
@@ -474,7 +509,7 @@ export default function ReverseProxyTargets(props: {
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
rewritePathType: null
});
}
@@ -494,16 +529,36 @@ export default function ReverseProxyTargets(props: {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site?.type || null
}
...target,
...data,
updated: true,
siteType: site?.type || null
}
: target
)
);
}
function updateTargetHealthCheck(targetId: number, config: any) {
setTargets(
targets.map((target) =>
target.targetId === targetId
? {
...target,
...config,
updated: true
}
: target
)
);
}
const openHealthCheckDialog = (target: LocalTarget) => {
console.log(target);
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
};
async function saveAllSettings() {
try {
setTargetsLoading(true);
@@ -518,6 +573,17 @@ export default function ReverseProxyTargets(props: {
method: target.method,
enabled: target.enabled,
siteId: target.siteId,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath || null,
hcScheme: target.hcScheme || null,
hcHostname: target.hcHostname || null,
hcPort: target.hcPort || null,
hcInterval: target.hcInterval || null,
hcTimeout: target.hcTimeout || null,
hcHeaders: target.hcHeaders || null,
hcFollowRedirects: target.hcFollowRedirects || null,
hcMethod: target.hcMethod || null,
hcStatus: target.hcStatus || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
@@ -598,16 +664,20 @@ export default function ReverseProxyTargets(props: {
accessorKey: "path",
header: t("matchPath"),
cell: ({ row }) => {
const hasPathMatch = !!(row.original.path || row.original.pathMatchType);
const hasPathMatch = !!(
row.original.path || row.original.pathMatchType
);
return hasPathMatch ? (
<div className="flex items-center gap-1">
<PathMatchModal
value={{
path: row.original.path,
pathMatchType: row.original.pathMatchType,
pathMatchType: row.original.pathMatchType
}}
onChange={(config) => updateTarget(row.original.targetId, config)}
onChange={(config) =>
updateTarget(row.original.targetId, config)
}
trigger={
<Button
variant="outline"
@@ -616,7 +686,8 @@ export default function ReverseProxyTargets(props: {
<PathMatchDisplay
value={{
path: row.original.path,
pathMatchType: row.original.pathMatchType,
pathMatchType:
row.original.pathMatchType
}}
/>
</Button>
@@ -646,9 +717,11 @@ export default function ReverseProxyTargets(props: {
<PathMatchModal
value={{
path: row.original.path,
pathMatchType: row.original.pathMatchType,
pathMatchType: row.original.pathMatchType
}}
onChange={(config) => updateTarget(row.original.targetId, config)}
onChange={(config) =>
updateTarget(row.original.targetId, config)
}
trigger={
<Button variant="outline">
<Plus className="h-4 w-4 mr-2" />
@@ -657,7 +730,7 @@ export default function ReverseProxyTargets(props: {
}
/>
);
},
}
},
{
accessorKey: "siteId",
@@ -693,7 +766,7 @@ export default function ReverseProxyTargets(props: {
className={cn(
"justify-between flex-1",
!row.original.siteId &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
{row.original.siteId
@@ -772,31 +845,31 @@ export default function ReverseProxyTargets(props: {
},
...(resource.http
? [
{
accessorKey: "method",
header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger>
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
}
]
{
accessorKey: "method",
header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select
defaultValue={row.original.method ?? ""}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger>
{row.original.method}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)
}
]
: []),
{
accessorKey: "ip",
@@ -860,8 +933,11 @@ export default function ReverseProxyTargets(props: {
accessorKey: "rewritePath",
header: t("rewritePath"),
cell: ({ row }) => {
const hasRewritePath = !!(row.original.rewritePath || row.original.rewritePathType);
const noPathMatch = !row.original.path && !row.original.pathMatchType;
const hasRewritePath = !!(
row.original.rewritePath || row.original.rewritePathType
);
const noPathMatch =
!row.original.path && !row.original.pathMatchType;
return hasRewritePath && !noPathMatch ? (
<div className="flex items-center gap-1">
@@ -869,9 +945,11 @@ export default function ReverseProxyTargets(props: {
<PathRewriteModal
value={{
rewritePath: row.original.rewritePath,
rewritePathType: row.original.rewritePathType,
rewritePathType: row.original.rewritePathType
}}
onChange={(config) => updateTarget(row.original.targetId, config)}
onChange={(config) =>
updateTarget(row.original.targetId, config)
}
trigger={
<Button
variant="outline"
@@ -880,8 +958,10 @@ export default function ReverseProxyTargets(props: {
>
<PathRewriteDisplay
value={{
rewritePath: row.original.rewritePath,
rewritePathType: row.original.rewritePathType,
rewritePath:
row.original.rewritePath,
rewritePathType:
row.original.rewritePathType
}}
/>
</Button>
@@ -896,7 +976,7 @@ export default function ReverseProxyTargets(props: {
updateTarget(row.original.targetId, {
...row.original,
rewritePath: null,
rewritePathType: null,
rewritePathType: null
});
}}
>
@@ -907,9 +987,11 @@ export default function ReverseProxyTargets(props: {
<PathRewriteModal
value={{
rewritePath: row.original.rewritePath,
rewritePathType: row.original.rewritePathType,
rewritePathType: row.original.rewritePathType
}}
onChange={(config) => updateTarget(row.original.targetId, config)}
onChange={(config) =>
updateTarget(row.original.targetId, config)
}
trigger={
<Button variant="outline" disabled={noPathMatch}>
<Plus className="h-4 w-4 mr-2" />
@@ -919,7 +1001,7 @@ export default function ReverseProxyTargets(props: {
disabled={noPathMatch}
/>
);
},
}
},
// {
@@ -940,6 +1022,79 @@ export default function ReverseProxyTargets(props: {
// </Select>
// ),
// },
{
accessorKey: "healthCheck",
header: t("healthCheck"),
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
const getStatusColor = (status: string) => {
switch (status) {
case "healthy":
return "green";
case "unhealthy":
return "red";
case "unknown":
default:
return "secondary";
}
};
const getStatusText = (status: string) => {
switch (status) {
case "healthy":
return t("healthCheckHealthy");
case "unhealthy":
return t("healthCheckUnhealthy");
case "unknown":
default:
return t("healthCheckUnknown");
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CircleCheck className="w-3 h-3" />;
case "unhealthy":
return <CircleX className="w-3 h-3" />;
case "unknown":
default:
return null;
}
};
return (
<>
{row.original.siteType === "newt" ? (
<div className="flex items-center space-x-1">
<Badge variant={getStatusColor(status)}>
<div className="flex items-center gap-1">
{getStatusIcon(status)}
{getStatusText(status)}
</div>
</Badge>
<Button
variant="outline"
size="sm"
onClick={() =>
openHealthCheckDialog(row.original)
}
className="h-6 w-6 p-0"
>
<Settings className="h-3 w-3" />
</Button>
</div>
) : (
<span className="text-sm text-muted-foreground">
{t("healthCheckNotAvailable")}
</span>
)}
</>
);
}
},
{
accessorKey: "enabled",
header: t("enabled"),
@@ -1034,21 +1189,21 @@ export default function ReverseProxyTargets(props: {
className={cn(
"justify-between flex-1",
!field.value &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
(
site
) =>
site.siteId ===
field.value
)
?.name
: t(
"siteSelect"
)}
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
@@ -1114,34 +1269,34 @@ export default function ReverseProxyTargets(props: {
);
return selectedSite &&
selectedSite.type ===
"newt"
"newt"
? (() => {
const dockerState =
getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={
selectedSite
}
containers={
dockerState.containers
}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelect
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()
const dockerState =
getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={
selectedSite
}
containers={
dockerState.containers
}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelect
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()
: null;
})()}
</div>
@@ -1369,12 +1524,12 @@ export default function ReverseProxyTargets(props: {
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
@@ -1544,9 +1699,7 @@ export default function ReverseProxyTargets(props: {
</FormLabel>
<FormControl>
<HeadersInput
value={
field.value
}
value={field.value}
onChange={(value) => {
field.onChange(
value
@@ -1588,6 +1741,56 @@ export default function ReverseProxyTargets(props: {
{t("saveSettings")}
</Button>
</div>
{selectedTargetForHealthCheck && (
<HealthCheckDialog
open={healthCheckDialogOpen}
setOpen={setHealthCheckDialogOpen}
targetId={selectedTargetForHealthCheck.targetId}
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
targetMethod={
selectedTargetForHealthCheck.method || undefined
}
initialConfig={{
hcEnabled:
selectedTargetForHealthCheck.hcEnabled || false,
hcPath: selectedTargetForHealthCheck.hcPath || "/",
hcMethod:
selectedTargetForHealthCheck.hcMethod || "GET",
hcInterval:
selectedTargetForHealthCheck.hcInterval || 5,
hcTimeout: selectedTargetForHealthCheck.hcTimeout || 5,
hcHeaders:
selectedTargetForHealthCheck.hcHeaders || undefined,
hcScheme:
selectedTargetForHealthCheck.hcScheme || undefined,
hcHostname:
selectedTargetForHealthCheck.hcHostname ||
selectedTargetForHealthCheck.ip,
hcPort:
selectedTargetForHealthCheck.hcPort ||
selectedTargetForHealthCheck.port,
hcFollowRedirects:
selectedTargetForHealthCheck.hcFollowRedirects ||
true,
hcStatus:
selectedTargetForHealthCheck.hcStatus || undefined,
hcMode: selectedTargetForHealthCheck.hcMode || "http",
hcUnhealthyInterval:
selectedTargetForHealthCheck.hcUnhealthyInterval ||
30
}}
onChanges={async (config) => {
if (selectedTargetForHealthCheck) {
console.log(config);
updateTargetHealthCheck(
selectedTargetForHealthCheck.targetId,
config
);
}
}}
/>
)}
</SettingsContainer>
);
}

View File

@@ -58,7 +58,7 @@ import {
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react";
import { ArrowUpDown, Check, InfoIcon, X, ChevronsUpDown } from "lucide-react";
import {
InfoSection,
InfoSections,
@@ -73,6 +73,20 @@ import {
import { Switch } from "@app/components/ui/switch";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { COUNTRIES } from "@server/db/countries";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
// Schema for rule validation
const addRuleSchema = z.object({
@@ -98,9 +112,13 @@ export default function ResourceRules(props: {
const [loading, setLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
const [openCountrySelect, setOpenCountrySelect] = useState(false);
const [countrySelectValue, setCountrySelectValue] = useState("");
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false);
const router = useRouter();
const t = useTranslations();
const env = useEnvContext();
const isMaxmindAvailable = env.env.server.maxmind_db_path && env.env.server.maxmind_db_path.length > 0;
const RuleAction = {
ACCEPT: t('alwaysAllow'),
@@ -111,7 +129,8 @@ export default function ResourceRules(props: {
const RuleMatch = {
PATH: t('path'),
IP: "IP",
CIDR: t('ipAddressRange')
CIDR: t('ipAddressRange'),
GEOIP: t('country')
} as const;
const addRuleForm = useForm({
@@ -193,6 +212,15 @@ export default function ResourceRules(props: {
setLoading(false);
return;
}
if (data.match === "GEOIP" && !COUNTRIES.some(c => c.code === data.value)) {
toast({
variant: "destructive",
title: t('rulesErrorInvalidCountry'),
description: t('rulesErrorInvalidCountryDescription') || "Invalid country code."
});
setLoading(false);
return;
}
// find the highest priority and add one
let priority = data.priority;
@@ -242,6 +270,8 @@ export default function ResourceRules(props: {
return t('rulesMatchIpAddress');
case "PATH":
return t('rulesMatchUrl');
case "GEOIP":
return t('rulesMatchCountry');
}
}
@@ -461,8 +491,8 @@ export default function ResourceRules(props: {
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
updateRule(row.original.ruleId, { match: value })
onValueChange={(value: "CIDR" | "IP" | "PATH" | "GEOIP") =>
updateRule(row.original.ruleId, { match: value, value: value === "GEOIP" ? "US" : row.original.value })
}
>
<SelectTrigger className="min-w-[125px]">
@@ -472,6 +502,9 @@ export default function ResourceRules(props: {
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
{isMaxmindAvailable && (
<SelectItem value="GEOIP">{RuleMatch.GEOIP}</SelectItem>
)}
</SelectContent>
</Select>
)
@@ -480,15 +513,61 @@ export default function ResourceRules(props: {
accessorKey: "value",
header: t('value'),
cell: ({ row }) => (
<Input
defaultValue={row.original.value}
className="min-w-[200px]"
onBlur={(e) =>
updateRule(row.original.ruleId, {
value: e.target.value
})
}
/>
row.original.match === "GEOIP" ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="min-w-[200px] justify-between"
>
{row.original.value
? COUNTRIES.find((country) => country.code === row.original.value)?.name +
" (" + row.original.value + ")"
: t('selectCountry')}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[200px] p-0">
<Command>
<CommandInput placeholder={t('searchCountries')} />
<CommandList>
<CommandEmpty>{t('noCountryFound')}</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
updateRule(row.original.ruleId, { value: country.code });
}}
>
<Check
className={`mr-2 h-4 w-4 ${
row.original.value === country.code
? "opacity-100"
: "opacity-0"
}`}
/>
{country.name} ({country.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
defaultValue={row.original.value}
className="min-w-[200px]"
onBlur={(e) =>
updateRule(row.original.ruleId, {
value: e.target.value
})
}
/>
)
)
},
{
@@ -650,9 +729,7 @@ export default function ResourceRules(props: {
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
onValueChange={field.onChange}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -669,6 +746,11 @@ export default function ResourceRules(props: {
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
{isMaxmindAvailable && (
<SelectItem value="GEOIP">
{RuleMatch.GEOIP}
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
@@ -692,7 +774,55 @@ export default function ResourceRules(props: {
}
/>
<FormControl>
<Input {...field}/>
{addRuleForm.watch("match") === "GEOIP" ? (
<Popover open={openAddRuleCountrySelect} onOpenChange={setOpenAddRuleCountrySelect}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openAddRuleCountrySelect}
className="w-full justify-between"
>
{field.value
? COUNTRIES.find((country) => country.code === field.value)?.name +
" (" + field.value + ")"
: t('selectCountry')}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t('searchCountries')} />
<CommandList>
<CommandEmpty>{t('noCountryFound')}</CommandEmpty>
<CommandGroup>
{COUNTRIES.map((country) => (
<CommandItem
key={country.code}
value={country.name}
onSelect={() => {
field.onChange(country.code);
setOpenAddRuleCountrySelect(false);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
field.value === country.code
? "opacity-100"
: "opacity-0"
}`}
/>
{country.name} ({country.code})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -340,7 +340,21 @@ export default function Page() {
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: 0 // Will be set when resource is created
resourceId: 0, // Will be set when resource is created
hcEnabled: false,
hcPath: null,
hcMethod: null,
hcInterval: null,
hcTimeout: null,
hcHeaders: null,
hcScheme: null,
hcHostname: null,
hcPort: null,
hcFollowRedirects: null,
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null
};
setTargets([...targets, newTarget]);
@@ -446,6 +460,18 @@ export default function Page() {
method: target.method,
enabled: target.enabled,
siteId: target.siteId,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath || null,
hcMethod: target.hcMethod || null,
hcInterval: target.hcInterval || null,
hcTimeout: target.hcTimeout || null,
hcHeaders: target.hcHeaders || null,
hcScheme: target.hcScheme || null,
hcHostname: target.hcHostname || null,
hcPort: target.hcPort || null,
hcFollowRedirects:
target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,

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 { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { generateKeypair } from "../[niceId]/wireguardConfig";
@@ -56,6 +53,7 @@ import {
CreateSiteResponse,
PickSiteDefaultsResponse
} from "@server/routers/site";
import { ListRemoteExitNodesResponse } from "@server/routers/private/remoteExitNode";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
@@ -73,6 +71,13 @@ interface TunnelTypeOption {
disabled?: boolean;
}
interface RemoteExitNodeOption {
id: string;
title: string;
description: string;
disabled?: boolean;
}
type Commands = {
mac: Record<string, string[]>;
linux: Record<string, string[]>;
@@ -115,7 +120,8 @@ export default function Page() {
method: z.enum(["newt", "wireguard", "local"]),
copied: z.boolean(),
clientAddress: z.string().optional(),
acceptClients: z.boolean()
acceptClients: z.boolean(),
exitNodeId: z.number().optional()
})
.refine(
(data) => {
@@ -123,12 +129,25 @@ export default function Page() {
// return data.copied;
return true;
}
return true;
// For local sites, require exitNodeId
return build == "saas" ? data.exitNodeId !== undefined : true;
},
{
message: t("sitesConfirmCopy"),
path: ["copied"]
}
)
.refine(
(data) => {
if (data.method === "local" && build == "saas") {
return data.exitNodeId !== undefined;
}
return true;
},
{
message: t("remoteExitNodeRequired"),
path: ["exitNodeId"]
}
);
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
@@ -148,7 +167,10 @@ export default function Page() {
{
id: "wireguard" as SiteType,
title: t("siteWg"),
description: build == "saas" ? t("siteWgDescriptionSaas") : t("siteWgDescription"),
description:
build == "saas"
? t("siteWgDescriptionSaas")
: t("siteWgDescription"),
disabled: true
}
]),
@@ -158,7 +180,10 @@ export default function Page() {
{
id: "local" as SiteType,
title: t("local"),
description: build == "saas" ? t("siteLocalDescriptionSaas") : t("siteLocalDescription")
description:
build == "saas"
? t("siteLocalDescriptionSaas")
: t("siteLocalDescription")
}
])
]);
@@ -184,6 +209,13 @@ export default function Page() {
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [remoteExitNodeOptions, setRemoteExitNodeOptions] = useState<
ReadonlyArray<RemoteExitNodeOption>
>([]);
const [selectedExitNodeId, setSelectedExitNodeId] = useState<
string | undefined
>();
const hydrateWireGuardConfig = (
privateKey: string,
publicKey: string,
@@ -320,7 +352,7 @@ WantedBy=default.target`
nixos: {
All: [
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
],
]
// aarch64: [
// `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
// ]
@@ -432,7 +464,8 @@ WantedBy=default.target`
copied: false,
method: "newt",
clientAddress: "",
acceptClients: false
acceptClients: false,
exitNodeId: undefined
}
});
@@ -482,6 +515,22 @@ WantedBy=default.target`
address: clientAddress
};
}
if (data.method === "local" && build == "saas") {
if (!data.exitNodeId) {
toast({
variant: "destructive",
title: t("siteErrorCreate"),
description: t("remoteExitNodeRequired")
});
setCreateLoading(false);
return;
}
payload = {
...payload,
exitNodeId: data.exitNodeId
};
}
const res = await api
.put<
@@ -533,7 +582,7 @@ WantedBy=default.target`
currentNewtVersion = latestVersion;
setNewtVersion(latestVersion);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (error instanceof Error && error.name === "AbortError") {
console.error(t("newtErrorFetchTimeout"));
} else {
console.error(
@@ -558,8 +607,10 @@ WantedBy=default.target`
await api
.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
// update the default value of the form to be local method only if local sites are not disabled
if (!env.flags.disableLocalSites) {
form.setValue("method", "local");
}
})
.then((res) => {
if (res && res.status === 200) {
@@ -602,6 +653,37 @@ WantedBy=default.target`
}
});
if (build === "saas") {
// Fetch remote exit nodes for local sites
try {
const remoteExitNodesRes = await api.get<
AxiosResponse<ListRemoteExitNodesResponse>
>(`/org/${orgId}/remote-exit-nodes`);
if (
remoteExitNodesRes &&
remoteExitNodesRes.status === 200
) {
const exitNodes =
remoteExitNodesRes.data.data.remoteExitNodes;
// Convert to options for StrategySelect
const exitNodeOptions: RemoteExitNodeOption[] =
exitNodes
.filter((node) => node.exitNodeId !== null)
.map((node) => ({
id: node.exitNodeId!.toString(),
title: node.name,
description: `${node.address?.split("/")[0] || "N/A"} - ${node.endpoint || "N/A"}`
}));
setRemoteExitNodeOptions(exitNodeOptions);
}
} catch (error) {
console.error("Failed to fetch remote exit nodes:", error);
}
}
setLoadingPage(false);
};
@@ -613,6 +695,18 @@ WantedBy=default.target`
form.setValue("acceptClients", acceptClients);
}, [acceptClients, form]);
// Sync form exitNodeId value with local state
useEffect(() => {
if (build !== "saas") {
// dont update the form
return;
}
form.setValue(
"exitNodeId",
selectedExitNodeId ? parseInt(selectedExitNodeId) : undefined
);
}, [selectedExitNodeId, form]);
return (
<>
<div className="flex justify-between">
@@ -920,7 +1014,7 @@ WantedBy=default.target`
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(
checked
@@ -1023,6 +1117,52 @@ WantedBy=default.target`
</SettingsSectionBody>
</SettingsSection>
)}
{build == "saas" &&
form.watch("method") === "local" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("remoteExitNodeSelection")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"remoteExitNodeSelectionDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{remoteExitNodeOptions.length > 0 ? (
<StrategySelect
options={remoteExitNodeOptions}
defaultValue={
selectedExitNodeId
}
onChange={(value) => {
setSelectedExitNodeId(
value
);
}}
cols={1}
/>
) : (
<Alert variant="destructive">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t(
"noRemoteExitNodesAvailable"
)}
</AlertTitle>
<AlertDescription>
{t(
"noRemoteExitNodesAvailableDescription"
)}
</AlertDescription>
</Alert>
)}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">

View File

@@ -52,6 +52,8 @@ export default async function SitesPage(props: SitesPageProps) {
online: site.online,
newtVersion: site.newtVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
};
});

View File

@@ -0,0 +1,193 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { formatAxiosError, priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { pullEnv } from "@app/lib/pullEnv";
import { LoginFormIDP } from "@app/components/LoginForm";
import { ListOrgIdpsResponse } from "@server/routers/private/orgIdp";
import { build } from "@server/build";
import { headers } from "next/headers";
import {
GetLoginPageResponse,
LoadLoginPageResponse
} from "@server/routers/private/loginPage";
import IdpLoginButtons from "@app/components/private/IdpLoginButtons";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/privateGetSessionTransferToken";
import { TransferSessionResponse } from "@server/routers/auth/privateTransferSession";
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
import { GetOrgTierResponse } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
export const dynamic = "force-dynamic";
export default async function OrgAuthPage(props: {
params: Promise<{}>;
searchParams: Promise<{ token?: string }>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const env = pullEnv();
const authHeader = await authCookieHeader();
if (searchParams.token) {
return <ValidateSessionTransferToken token={searchParams.token} />;
}
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const allHeaders = await headers();
const host = allHeaders.get("host");
const t = await getTranslations();
const expectedHost = env.app.dashboardUrl.split("//")[1];
let loginPage: LoadLoginPageResponse | undefined;
if (host !== expectedHost) {
try {
const res = await priv.get<AxiosResponse<LoadLoginPageResponse>>(
`/login-page?fullDomain=${host}`
);
if (res && res.status === 200) {
loginPage = res.data.data;
}
} catch (e) {}
if (!loginPage) {
redirect(env.app.dashboardUrl);
}
let subscriptionStatus: GetOrgTierResponse | null = null;
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
`/org/${loginPage!.orgId}/billing/tier`
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
if (build === "saas" && !subscribed) {
redirect(env.app.dashboardUrl);
}
if (user) {
let redirectToken: string | undefined;
try {
const res = await priv.post<
AxiosResponse<GetSessionTransferTokenRenponse>
>(`/get-session-transfer-token`, {}, authHeader);
if (res && res.status === 200) {
const newToken = res.data.data.token;
redirectToken = newToken;
}
} catch (e) {
console.error(
formatAxiosError(e, "Failed to get transfer token")
);
}
if (redirectToken) {
redirect(
`${env.app.dashboardUrl}/auth/org?token=${redirectToken}`
);
}
}
} else {
redirect(env.app.dashboardUrl);
}
let loginIdps: LoginFormIDP[] = [];
if (build === "saas") {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${loginPage!.orgId}/idp`
)
)();
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
}
return (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://digpangolin.com/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{env.branding.appName || "Pangolin"}
</Link>
</span>
</div>
<Card className="shadow-md w-full max-w-md">
<CardHeader>
<CardTitle>{t("orgAuthSignInTitle")}</CardTitle>
<CardDescription>
{loginIdps.length > 0
? t("orgAuthChooseIdpDescription")
: ""}
</CardDescription>
</CardHeader>
<CardContent>
{loginIdps.length > 0 ? (
<IdpLoginButtons
idps={loginIdps}
orgId={loginPage?.orgId}
/>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("orgAuthNoIdpConfigured")}
</p>
<Link href={`${env.app.dashboardUrl}/auth/login`}>
<Button className="w-full">
{t("orgAuthSignInWithPangolin")}
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,10 +1,13 @@
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import ValidateOidcToken from "@app/components/ValidateOidcToken";
import { cache } from "react";
import { priv } from "@app/lib/api";
import { formatAxiosError, priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetIdpResponse } from "@server/routers/idp";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { LoadLoginPageResponse } from "@server/routers/private/loginPage";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
@@ -33,10 +36,34 @@ export default async function Page(props: {
return <div>{t('idpErrorNotFound')}</div>;
}
const allHeaders = await headers();
const host = allHeaders.get("host");
const env = pullEnv();
const expectedHost = env.app.dashboardUrl.split("//")[1];
let loginPage: LoadLoginPageResponse | undefined;
if (host !== expectedHost) {
try {
const res = await priv.get<AxiosResponse<LoadLoginPageResponse>>(
`/login-page?idpId=${foundIdp.idpId}&fullDomain=${host}`
);
if (res && res.status === 200) {
loginPage = res.data.data;
}
} catch (e) {
console.error(formatAxiosError(e));
}
if (!loginPage) {
redirect(env.app.dashboardUrl);
}
}
return (
<>
<ValidateOidcToken
orgId={params.orgId}
loginPageId={loginPage?.loginPageId}
idpId={params.idpId}
code={searchParams.code}
expectedState={searchParams.state}

View File

@@ -12,7 +12,7 @@ import { cache } from "react";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: `Auth - Pangolin`,
title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
description: ""
};

View File

@@ -12,6 +12,7 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { ListIdpsResponse } from "@server/routers/idp";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -37,14 +38,17 @@ export default async function Page(props: {
redirectUrl = cleanRedirect(searchParams.redirect as string);
}
const idpsRes = await cache(
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
const loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
let loginIdps: LoginFormIDP[] = [];
if (build !== "saas") {
const idpsRes = await cache(
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
})) as LoginFormIDP[];
}
const t = await getTranslations();

View File

@@ -3,7 +3,7 @@ import {
GetExchangeTokenResponse
} from "@server/routers/resource";
import ResourceAuthPortal from "@app/components/ResourceAuthPortal";
import { internal, priv } from "@app/lib/api";
import { formatAxiosError, internal, priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
@@ -15,7 +15,13 @@ import AccessToken from "@app/components/AccessToken";
import { pullEnv } from "@app/lib/pullEnv";
import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse } from "@server/routers/idp";
import { ListOrgIdpsResponse } from "@server/routers/private/orgIdp";
import AutoLoginHandler from "@app/components/AutoLoginHandler";
import { build } from "@server/build";
import { headers } from "next/headers";
import { GetLoginPageResponse } from "@server/routers/private/loginPage";
import { GetOrgTierResponse } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
export const dynamic = "force-dynamic";
@@ -55,6 +61,45 @@ export default async function ResourceAuthPage(props: {
);
}
let subscriptionStatus: GetOrgTierResponse | null = null;
if (build == "saas") {
try {
const getSubscription = cache(() =>
priv.get<AxiosResponse<GetOrgTierResponse>>(
`/org/${authInfo.orgId}/billing/tier`
)
);
const subRes = await getSubscription();
subscriptionStatus = subRes.data.data;
} catch {}
}
const subscribed = subscriptionStatus?.tier === TierId.STANDARD;
const allHeaders = await headers();
const host = allHeaders.get("host");
const expectedHost = env.app.dashboardUrl.split("//")[1];
if (host !== expectedHost) {
if (build === "saas" && !subscribed) {
redirect(env.app.dashboardUrl);
}
let loginPage: GetLoginPageResponse | undefined;
try {
const res = await priv.get<AxiosResponse<GetLoginPageResponse>>(
`/login-page?resourceId=${authInfo.resourceId}&fullDomain=${host}`
);
if (res && res.status === 200) {
loginPage = res.data.data;
}
} catch (e) {}
if (!loginPage) {
redirect(env.app.dashboardUrl);
}
}
let redirectUrl = authInfo.url;
if (searchParams.redirect) {
try {
@@ -136,13 +181,31 @@ export default async function ResourceAuthPage(props: {
);
}
const idpsRes = await cache(
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
const loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name
})) as LoginFormIDP[];
let loginIdps: LoginFormIDP[] = [];
if (build === "saas") {
if (subscribed) {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${authInfo!.orgId}/idp`
)
)();
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
}
} else {
const idpsRes = await cache(
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
})) as LoginFormIDP[];
}
if (authInfo.skipToIdpId && authInfo.skipToIdpId !== null) {
const idp = loginIdps.find((idp) => idp.idpId === authInfo.skipToIdpId);
@@ -152,6 +215,7 @@ export default async function ResourceAuthPage(props: {
resourceId={authInfo.resourceId}
skipToIdpId={authInfo.skipToIdpId}
redirectUrl={redirectUrl}
orgId={build == "saas" ? authInfo.orgId : undefined}
/>
);
}
@@ -178,6 +242,7 @@ export default async function ResourceAuthPage(props: {
}}
redirect={redirectUrl}
idps={loginIdps}
orgId={build === "saas" ? authInfo.orgId : undefined}
/>
</div>
)}

View File

@@ -4,6 +4,8 @@ import { Inter } from "next/font/google";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
import ThemeDataProvider from "@app/providers/PrivateThemeDataProvider";
import SplashImage from "@app/components/private/SplashImage";
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
@@ -17,13 +19,24 @@ import { getLocale } from "next-intl/server";
import { Toaster } from "@app/components/ui/toaster";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
description: "",
...(process.env.BRANDING_FAVICON_PATH
? {
icons: {
icon: [
{
url: process.env.BRANDING_FAVICON_PATH as string
}
]
}
}
: {})
};
export const dynamic = "force-dynamic";
// const font = Figtree({ subsets: ["latin"] });
const font = Inter({ subsets: ["latin"] });
export default async function RootLayout({
@@ -62,25 +75,44 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
<EnvProvider env={pullEnv()}>
<LicenseStatusProvider licenseStatus={licenseStatus}>
<SupportStatusProvider
supporterStatus={supporterData}
<ThemeDataProvider colors={loadBrandingColors()}>
<EnvProvider env={pullEnv()}>
<LicenseStatusProvider
licenseStatus={licenseStatus}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<LicenseViolation />
{children}
<SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<SplashImage>
<LicenseViolation />
{children}
</SplashImage>
<LicenseViolation />
</div>
</div>
</div>
</SupportStatusProvider>
</LicenseStatusProvider>
</EnvProvider>
<Toaster />
</SupportStatusProvider>
</LicenseStatusProvider>
<Toaster />
</EnvProvider>
</ThemeDataProvider>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);
}
}
function loadBrandingColors() {
// this is loaded once on the server and not included in pullEnv
// so we don't need to parse the json every time pullEnv is called
if (process.env.BRANDING_COLORS) {
try {
return JSON.parse(process.env.BRANDING_COLORS);
} catch (e) {
console.error("Failed to parse BRANDING_COLORS", e);
}
}
}

View File

@@ -14,6 +14,7 @@ import {
User,
Globe, // Added from 'dev' branch
MonitorUp, // Added from 'dev' branch
Server,
Zap
} from "lucide-react";
@@ -57,6 +58,15 @@ export const orgNavSections = (
}
]
: []),
...(build == "saas"
? [
{
title: "sidebarRemoteExitNodes",
href: "/{orgId}/settings/remote-exit-nodes",
icon: <Server className="h-4 w-4" />
}
]
: []),
{
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
@@ -82,6 +92,15 @@ export const orgNavSections = (
href: "/{orgId}/settings/access/invitations",
icon: <TicketCheck className="h-4 w-4" />
},
...(build == "saas"
? [
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="h-4 w-4" />
}
]
: []),
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
@@ -97,6 +116,15 @@ export const orgNavSections = (
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />
},
...(build == "saas"
? [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <TicketCheck className="h-4 w-4" />
}
]
: []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",

View File

@@ -32,7 +32,7 @@ export default async function Page(props: {
let complete = false;
try {
const setupRes = await internal.get<
AxiosResponse<InitialSetupCompleteResponse>
AxiosResponse<InitialSetupCompleteResponse>
>(`/auth/initial-setup-complete`, await authCookieHeader());
complete = setupRes.data.data.complete;
} catch (e) {}
@@ -83,7 +83,10 @@ export default async function Page(props: {
if (lastOrgExists) {
redirect(`/${lastOrgCookie}`);
} else {
const ownedOrg = orgs.find((org) => org.isOwner);
let ownedOrg = orgs.find((org) => org.isOwner);
if (!ownedOrg) {
ownedOrg = orgs[0];
}
if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`);
} else {

View File

@@ -12,7 +12,7 @@ import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
export const metadata: Metadata = {
title: `Setup - Pangolin`,
title: `Setup - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
description: ""
};