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

@@ -1,6 +1,6 @@
"use server";
import { cookies } from "next/headers";
import { cookies, headers as reqHeaders } from "next/headers";
import { ResponseT } from "@server/types/Response";
import { pullEnv } from "@app/lib/pullEnv";
@@ -14,7 +14,10 @@ type CookieOptions = {
domain?: string;
};
function parseSetCookieString(setCookie: string): {
function parseSetCookieString(
setCookie: string,
host?: string
): {
name: string;
value: string;
options: CookieOptions;
@@ -50,16 +53,11 @@ function parseSetCookieString(setCookie: string): {
case "max-age":
options.maxAge = parseInt(v, 10);
break;
case "domain":
options.domain = v;
break;
}
}
if (!options.domain) {
const d = env.app.dashboardUrl
? "." + new URL(env.app.dashboardUrl).hostname
: undefined;
const d = host ? host : new URL(env.app.dashboardUrl).hostname;
if (d) {
options.domain = d;
}
@@ -78,6 +76,9 @@ async function makeApiRequest<T>(
const allCookies = await cookies();
const cookieHeader = allCookies.toString();
const headersList = await reqHeaders();
const host = headersList.get("host");
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-CSRF-Token": "x-csrf-protection",
@@ -107,7 +108,10 @@ async function makeApiRequest<T>(
const rawSetCookie = res.headers.get("set-cookie");
if (rawSetCookie) {
try {
const { name, value, options } = parseSetCookieString(rawSetCookie);
const { name, value, options } = parseSetCookieString(
rawSetCookie,
host || undefined
);
const allCookies = await cookies();
allCookies.set(name, value, options);
} catch (cookieError) {

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: ""
};

View File

@@ -21,12 +21,14 @@ type AutoLoginHandlerProps = {
resourceId: number;
skipToIdpId: number;
redirectUrl: string;
orgId?: string;
};
export default function AutoLoginHandler({
resourceId,
skipToIdpId,
redirectUrl
redirectUrl,
orgId
}: AutoLoginHandlerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
@@ -44,7 +46,8 @@ export default function AutoLoginHandler({
try {
const response = await generateOidcUrlProxy(
skipToIdpId,
redirectUrl
redirectUrl,
orgId
);
if (response.error) {
@@ -83,7 +86,9 @@ export default function AutoLoginHandler({
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("autoLoginTitle")}</CardTitle>
<CardDescription>{t("autoLoginDescription")}</CardDescription>
<CardDescription>
{t("autoLoginDescription")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (

View File

@@ -27,10 +27,12 @@ export default function BrandingLogo(props: BrandingLogoProps) {
}
if (lightOrDark === "light") {
return "/logo/word_mark_black.png";
return (
env.branding.logo?.lightPath || "/logo/word_mark_black.png"
);
}
return "/logo/word_mark_white.png";
return env.branding.logo?.darkPath || "/logo/word_mark_white.png";
}
const path = getPath();

View File

@@ -38,7 +38,10 @@ export default function DashboardLoginForm({
<Card className="shadow-md w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={58} width={175} />
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
/>
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>

View File

@@ -32,6 +32,7 @@ import { createApiClient, formatAxiosError } from "@/lib/api";
import { useEnvContext } from "@/hooks/useEnvContext";
import { toast } from "@/hooks/useToast";
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/privateCheckDomainNamespaceAvailability";
import { AxiosResponse } from "axios";
import { cn } from "@/lib/cn";
import { useTranslations } from "next-intl";
@@ -155,7 +156,10 @@ export default function DomainPicker2({
fullDomain: firstOrgDomain.baseDomain,
baseDomain: firstOrgDomain.baseDomain
});
} else if ((build === "saas" || build === "enterprise") && !hideFreeDomain) {
} else if (
(build === "saas" || build === "enterprise") &&
!hideFreeDomain
) {
// If no organization domains, select the provided domain option
const domainOptionText =
build === "enterprise"
@@ -198,7 +202,21 @@ export default function DomainPicker2({
.toLowerCase()
.replace(/\./g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-");
.replace(/-+/g, "-") // Replace multiple consecutive dashes with single dash
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
if (build != "oss") {
const response = await api.get<
AxiosResponse<CheckDomainAvailabilityResponse>
>(
`/domain/check-namespace-availability?subdomain=${encodeURIComponent(checkSubdomain)}`
);
if (response.status === 200) {
const { options } = response.data.data;
setAvailableOptions(options);
}
}
} catch (error) {
console.error("Failed to check domain availability:", error);
setAvailableOptions([]);
@@ -272,13 +290,16 @@ export default function DomainPicker2({
toast({
variant: "destructive",
title: t("domainPickerInvalidSubdomain"),
description: t("domainPickerInvalidSubdomainRemoved", { sub }),
description: t("domainPickerInvalidSubdomainRemoved", { sub })
});
return "";
}
const ok = validateByDomainType(sanitized, {
type: base.type === "provided-search" ? "provided-search" : "organization",
type:
base.type === "provided-search"
? "provided-search"
: "organization",
domainType: base.domainType
});
@@ -286,7 +307,10 @@ export default function DomainPicker2({
toast({
variant: "destructive",
title: t("domainPickerInvalidSubdomain"),
description: t("domainPickerInvalidSubdomainCannotMakeValid", { sub, domain: base.domain }),
description: t("domainPickerInvalidSubdomainCannotMakeValid", {
sub,
domain: base.domain
})
});
return "";
}
@@ -294,7 +318,10 @@ export default function DomainPicker2({
if (sub !== sanitized) {
toast({
title: t("domainPickerSubdomainSanitized"),
description: t("domainPickerSubdomainCorrected", { sub, sanitized }),
description: t("domainPickerSubdomainCorrected", {
sub,
sanitized
})
});
}
@@ -365,7 +392,8 @@ export default function DomainPicker2({
onDomainChange?.({
domainId: option.domainId || "",
domainNamespaceId: option.domainNamespaceId,
type: option.type === "provided-search" ? "provided" : "organization",
type:
option.type === "provided-search" ? "provided" : "organization",
subdomain: sub || undefined,
fullDomain,
baseDomain: option.domain
@@ -389,12 +417,16 @@ export default function DomainPicker2({
});
};
const isSubdomainValid = selectedBaseDomain && subdomainInput
? validateByDomainType(subdomainInput, {
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
domainType: selectedBaseDomain.domainType
})
: true;
const isSubdomainValid =
selectedBaseDomain && subdomainInput
? validateByDomainType(subdomainInput, {
type:
selectedBaseDomain.type === "provided-search"
? "provided-search"
: "organization",
domainType: selectedBaseDomain.domainType
})
: true;
const showSubdomainInput =
selectedBaseDomain &&
@@ -415,7 +447,6 @@ export default function DomainPicker2({
const hasMoreProvided =
sortedAvailableOptions.length > providedDomainsShown;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -434,16 +465,16 @@ export default function DomainPicker2({
showProvidedDomainSearch
? ""
: showSubdomainInput
? ""
: t("domainPickerNotAvailableForCname")
? ""
: t("domainPickerNotAvailableForCname")
}
disabled={
!showSubdomainInput && !showProvidedDomainSearch
}
className={cn(
!isSubdomainValid &&
subdomainInput &&
"border-red-500 focus:border-red-500"
subdomainInput &&
"border-red-500 focus:border-red-500"
)}
onChange={(e) => {
if (showProvidedDomainSearch) {
@@ -453,11 +484,13 @@ export default function DomainPicker2({
}
}}
/>
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
<p className="text-sm text-red-500">
{t("domainPickerInvalidSubdomainStructure")}
</p>
)}
{showSubdomainInput &&
subdomainInput &&
!isValidSubdomainStructure(subdomainInput) && (
<p className="text-sm text-red-500">
{t("domainPickerInvalidSubdomainStructure")}
</p>
)}
{showSubdomainInput && !subdomainInput && (
<p className="text-sm text-muted-foreground">
{t("domainPickerEnterSubdomainOrLeaveBlank")}
@@ -483,7 +516,7 @@ export default function DomainPicker2({
{selectedBaseDomain ? (
<div className="flex items-center space-x-2 min-w-0 flex-1">
{selectedBaseDomain.type ===
"organization" ? null : (
"organization" ? null : (
<Zap className="h-4 w-4 flex-shrink-0" />
)}
<span className="truncate">
@@ -557,8 +590,12 @@ export default function DomainPicker2({
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
? t("domainPickerVerified")
: t("domainPickerUnverified")}
? t(
"domainPickerVerified"
)
: t(
"domainPickerUnverified"
)}
</span>
</div>
<Check
@@ -576,21 +613,24 @@ export default function DomainPicker2({
</CommandList>
</CommandGroup>
{(build === "saas" ||
build === "enterprise") && !hideFreeDomain && (
build === "enterprise") &&
!hideFreeDomain && (
<CommandSeparator className="my-2" />
)}
</>
)}
{(build === "saas" ||
build === "enterprise") && !hideFreeDomain && (
{(build === "saas" || build === "enterprise") &&
!hideFreeDomain && (
<CommandGroup
heading={
build === "enterprise"
? t(
"domainPickerProvidedDomains"
)
: t("domainPickerFreeDomains")
"domainPickerProvidedDomains"
)
: t(
"domainPickerFreeDomains"
)
}
className="py-2"
>
@@ -602,9 +642,13 @@ export default function DomainPicker2({
id: "provided-search",
domain:
build ===
"enterprise"
? t("domainPickerProvidedDomain")
: t("domainPickerFreeProvidedDomain"),
"enterprise"
? t(
"domainPickerProvidedDomain"
)
: t(
"domainPickerFreeProvidedDomain"
),
type: "provided-search"
})
}
@@ -615,9 +659,14 @@ export default function DomainPicker2({
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
{build === "enterprise"
? t("domainPickerProvidedDomain")
: t("domainPickerFreeProvidedDomain")}
{build ===
"enterprise"
? t(
"domainPickerProvidedDomain"
)
: t(
"domainPickerFreeProvidedDomain"
)}
</span>
<span className="text-xs text-muted-foreground">
{t(
@@ -644,6 +693,15 @@ export default function DomainPicker2({
</div>
</div>
{/*showProvidedDomainSearch && build === "saas" && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerNotWorkSelfHosted")}
</AlertDescription>
</Alert>
)*/}
{showProvidedDomainSearch && (
<div className="space-y-4">
{isChecking && (
@@ -693,7 +751,7 @@ export default function DomainPicker2({
htmlFor={option.domainNamespaceId}
data-state={
selectedProvidedDomain?.domainNamespaceId ===
option.domainNamespaceId
option.domainNamespaceId
? "checked"
: "unchecked"
}

View File

@@ -0,0 +1,580 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { HeadersInput } from "@app/components/HeadersInput";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@/components/Credenza";
import { toast } from "@/hooks/useToast";
import { useTranslations } from "next-intl";
type HealthCheckConfig = {
hcEnabled: boolean;
hcPath: string;
hcMethod: string;
hcInterval: number;
hcTimeout: number;
hcStatus: number | null;
hcHeaders?: { name: string; value: string }[] | null;
hcScheme?: string;
hcHostname: string;
hcPort: number;
hcFollowRedirects: boolean;
hcMode: string;
hcUnhealthyInterval: number;
};
type HealthCheckDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
targetId: number;
targetAddress: string;
targetMethod?: string;
initialConfig?: Partial<HealthCheckConfig>;
onChanges: (config: HealthCheckConfig) => Promise<void>;
};
export default function HealthCheckDialog({
open,
setOpen,
targetId,
targetAddress,
targetMethod,
initialConfig,
onChanges
}: HealthCheckDialogProps) {
const t = useTranslations();
const healthCheckSchema = z.object({
hcEnabled: z.boolean(),
hcPath: z.string().min(1, { message: t("healthCheckPathRequired") }),
hcMethod: z
.string()
.min(1, { message: t("healthCheckMethodRequired") }),
hcInterval: z
.number()
.int()
.positive()
.min(5, { message: t("healthCheckIntervalMin") }),
hcTimeout: z
.number()
.int()
.positive()
.min(1, { message: t("healthCheckTimeoutMin") }),
hcStatus: z.number().int().positive().min(100).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcScheme: z.string().optional(),
hcHostname: z.string(),
hcPort: z.number().positive().gt(0).lte(65535),
hcFollowRedirects: z.boolean(),
hcMode: z.string(),
hcUnhealthyInterval: z.number().int().positive().min(5)
});
const form = useForm<z.infer<typeof healthCheckSchema>>({
resolver: zodResolver(healthCheckSchema),
defaultValues: {}
});
useEffect(() => {
if (!open) return;
// Determine default scheme from target method
const getDefaultScheme = () => {
if (initialConfig?.hcScheme) {
return initialConfig.hcScheme;
}
// Default to target method if it's http or https, otherwise default to http
if (targetMethod === "https") {
return "https";
}
return "http";
};
form.reset({
hcEnabled: initialConfig?.hcEnabled,
hcPath: initialConfig?.hcPath,
hcMethod: initialConfig?.hcMethod,
hcInterval: initialConfig?.hcInterval,
hcTimeout: initialConfig?.hcTimeout,
hcStatus: initialConfig?.hcStatus,
hcHeaders: initialConfig?.hcHeaders,
hcScheme: getDefaultScheme(),
hcHostname: initialConfig?.hcHostname,
hcPort: initialConfig?.hcPort,
hcFollowRedirects: initialConfig?.hcFollowRedirects,
hcMode: initialConfig?.hcMode,
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval
});
}, [open]);
const watchedEnabled = form.watch("hcEnabled");
const handleFieldChange = async (fieldName: string, value: any) => {
try {
const currentValues = form.getValues();
const updatedValues = { ...currentValues, [fieldName]: value };
await onChanges({
...updatedValues,
hcStatus: updatedValues.hcStatus || null
});
} catch (error) {
toast({
title: t("healthCheckError"),
description: t("healthCheckErrorDescription"),
variant: "destructive"
});
}
};
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("configureHealthCheck")}</CredenzaTitle>
<CredenzaDescription>
{t("configureHealthCheckDescription", {
target: targetAddress
})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form className="space-y-6">
{/* Enable Health Checks */}
<FormField
control={form.control}
name="hcEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base font-semibold">
{t("enableHealthChecks")}
</FormLabel>
<FormDescription>
{t(
"enableHealthChecksDescription"
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcEnabled",
value
);
}}
/>
</FormControl>
</FormItem>
)}
/>
{watchedEnabled && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<FormField
control={form.control}
name="hcScheme"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthScheme")}
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcScheme",
value
);
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"healthSelectScheme"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">
HTTP
</SelectItem>
<SelectItem value="https">
HTTPS
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcHostname"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthHostname")}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(
e
);
handleFieldChange(
"hcHostname",
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcPort"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthPort")}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcPort",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcPath"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("healthCheckPath")}
</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(
e
);
handleFieldChange(
"hcPath",
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* HTTP Method */}
<FormField
control={form.control}
name="hcMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("httpMethod")}
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcMethod",
value
);
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectHttpMethod"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="GET">
GET
</SelectItem>
<SelectItem value="POST">
POST
</SelectItem>
<SelectItem value="HEAD">
HEAD
</SelectItem>
<SelectItem value="PUT">
PUT
</SelectItem>
<SelectItem value="DELETE">
DELETE
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Check Interval, Timeout, and Retry Attempts */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="hcInterval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t(
"healthyIntervalSeconds"
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcInterval",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcUnhealthyInterval"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t(
"unhealthyIntervalSeconds"
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcUnhealthyInterval",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="hcTimeout"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("timeoutSeconds")}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcTimeout",
value
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormDescription>
{t("timeIsInSeconds")}
</FormDescription>
</div>
{/* Expected Response Codes */}
<FormField
control={form.control}
name="hcStatus"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("expectedResponseCodes")}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={
field.value || ""
}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
field.onChange(
value
);
handleFieldChange(
"hcStatus",
value
);
}}
/>
</FormControl>
<FormDescription>
{t(
"expectedResponseCodesDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Custom Headers */}
<FormField
control={form.control}
name="hcHeaders"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={(value) => {
field.onChange(value);
handleFieldChange(
"hcHeaders",
value
);
}}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button onClick={() => setOpen(false)}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -6,6 +6,10 @@ import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "./ui/badge";
import { build } from "@server/build";
interface LayoutHeaderProps {
showTopBar: boolean;
@@ -14,6 +18,7 @@ interface LayoutHeaderProps {
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
useEffect(() => {
function getPath() {
@@ -44,16 +49,18 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
<div className="h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
{path && (
<Image
src={path}
alt="Pangolin"
width={98}
height={32}
className="h-8 w-auto"
/>
)}
<BrandingLogo
width={
env.branding.logo?.navbar?.width || 98
}
height={
env.branding.logo?.navbar?.height || 32
}
/>
</Link>
{/* {build === "saas" && (
<Badge variant="secondary">Cloud Beta</Badge>
)} */}
</div>
{showTopBar && (

View File

@@ -57,6 +57,16 @@ export function LayoutSidebar({
setSidebarStateCookie(isSidebarCollapsed);
}, [isSidebarCollapsed]);
function loadFooterLinks(): { text: string; href?: string }[] | undefined {
if (env.branding.footer) {
try {
return JSON.parse(env.branding.footer);
} catch (e) {
console.error("Failed to parse BRANDING_FOOTER", e);
}
}
}
return (
<div
className={cn(
@@ -113,31 +123,62 @@ export function LayoutSidebar({
<SupporterStatus isCollapsed={isSidebarCollapsed} />
{!isSidebarCollapsed && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<FaGithub size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div>
{loadFooterLinks() ? (
<>
{loadFooterLinks()!.map((link, index) => (
<div
key={index}
className="whitespace-nowrap"
>
{link.href ? (
<div className="text-xs text-muted-foreground text-center">
<Link
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{link.text}
<ExternalLink size={12} />
</Link>
</div>
) : (
<div className="text-xs text-muted-foreground text-center">
{link.text}
</div>
)}
</div>
))}
</>
) : (
<>
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
{!isUnlocked()
? t("communityEdition")
: t("commercialEdition")}
<FaGithub size={12} />
</Link>
</div>
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
v{env.app.version}
<ExternalLink size={12} />
</Link>
</div>
)}
</>
)}
</div>
)}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
@@ -22,10 +22,7 @@ import {
CardTitle
} from "@app/components/ui/card";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { LoginResponse } from "@server/routers/auth";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { LockIcon, FingerprintIcon } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import {
@@ -49,6 +46,9 @@ import {
} from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
// @ts-ignore
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
export type LoginFormIDP = {
idpId: number;
@@ -60,13 +60,18 @@ type LoginFormProps = {
redirect?: string;
onLogin?: () => void | Promise<void>;
idps?: LoginFormIDP[];
orgId?: string;
};
export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
export default function LoginForm({
redirect,
onLogin,
idps,
orgId
}: LoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [error, setError] = useState<string | null>(null);
@@ -77,10 +82,32 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
const t = useTranslations();
const currentHost = typeof window !== "undefined" ? window.location.hostname : "";
const currentHost =
typeof window !== "undefined" ? window.location.hostname : "";
const expectedHost = new URL(env.app.dashboardUrl).host;
const isExpectedHost = currentHost === expectedHost;
const [reo, setReo] = useState<any | undefined>(undefined);
useEffect(() => {
async function init() {
if (env.app.environment !== "prod") {
return;
}
try {
const clientID = env.server.reoClientId;
const reoClient = await loadReoScript({ clientID });
await reoClient.init({ clientID });
setReo(reoClient);
} catch (e) {
console.error("Failed to load Reo script", e);
}
}
if (build == "saas") {
init();
}
}, []);
const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -183,26 +210,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
}
}
} catch (e: any) {
if (e.isAxiosError) {
setError(
formatAxiosError(
e,
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
)
);
} else {
console.error(e);
setError(
e.message ||
t("securityKeyAuthError", {
defaultValue:
"Failed to authenticate with security key"
})
);
}
console.error(e);
setError(
t("securityKeyAuthError", {
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
setLoading(false);
setShowSecurityKeyPrompt(false);
@@ -224,6 +238,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
code
});
try {
const identity = {
username: email,
type: "email" // can be one of email, github, linkedin, gmail, userID,
};
if (reo) {
reo.identify(identity);
}
} catch (e) {
console.error("Reo identify error:", e);
}
if (response.error) {
setError(response.message);
return;
@@ -253,7 +279,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
if (data.emailVerificationRequired) {
if (!isExpectedHost) {
setError(t("emailVerificationRequired", { dashboardUrl: env.app.dashboardUrl }));
setError(
t("emailVerificationRequired", {
dashboardUrl: env.app.dashboardUrl
})
);
return;
}
if (redirect) {
@@ -266,7 +296,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
if (data.twoFactorSetupRequired) {
if (!isExpectedHost) {
setError(t("twoFactorSetupRequired", { dashboardUrl: env.app.dashboardUrl }));
setError(
t("twoFactorSetupRequired", {
dashboardUrl: env.app.dashboardUrl
})
);
return;
}
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
@@ -278,25 +312,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
await onLogin();
}
} catch (e: any) {
if (e.isAxiosError) {
const errorMessage = formatAxiosError(
e,
t("loginError", {
defaultValue: "Failed to log in"
})
);
setError(errorMessage);
return;
} else {
console.error(e);
setError(
e.message ||
t("loginError", {
defaultValue: "Failed to log in"
})
);
return;
}
console.error(e);
setError(
t("loginError", {
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
setLoading(false);
}
@@ -307,7 +329,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
try {
const data = await generateOidcUrlProxy(
idpId,
redirect || "/"
redirect || "/",
orgId
);
const url = data.data?.redirectUrl;
if (data.error) {
@@ -527,7 +550,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
</div>
{idps.map((idp) => {
const effectiveType = idp.variant || idp.name.toLowerCase();
const effectiveType =
idp.variant || idp.name.toLowerCase();
return (
<Button

View File

@@ -8,6 +8,8 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { build } from "@server/build";
type PermissionsSelectBoxProps = {
root?: boolean;
@@ -17,6 +19,7 @@ type PermissionsSelectBoxProps = {
function getActionsCategories(root: boolean) {
const t = useTranslations();
const { env } = useEnvContext();
const actionsByCategory: Record<string, Record<string, string>> = {
Organization: {
@@ -34,12 +37,12 @@ function getActionsCategories(root: boolean) {
},
Site: {
[t('actionCreateSite')]: "createSite",
[t('actionDeleteSite')]: "deleteSite",
[t('actionGetSite')]: "getSite",
[t('actionListSites')]: "listSites",
[t('actionUpdateSite')]: "updateSite",
[t('actionListSiteRoles')]: "listSiteRoles"
[t("actionCreateSite")]: "createSite",
[t("actionDeleteSite")]: "deleteSite",
[t("actionGetSite")]: "getSite",
[t("actionListSites")]: "listSites",
[t("actionUpdateSite")]: "updateSite",
[t("actionListSiteRoles")]: "listSiteRoles"
},
Resource: {
@@ -64,26 +67,26 @@ function getActionsCategories(root: boolean) {
},
Target: {
[t('actionCreateTarget')]: "createTarget",
[t('actionDeleteTarget')]: "deleteTarget",
[t('actionGetTarget')]: "getTarget",
[t('actionListTargets')]: "listTargets",
[t('actionUpdateTarget')]: "updateTarget"
[t("actionCreateTarget")]: "createTarget",
[t("actionDeleteTarget")]: "deleteTarget",
[t("actionGetTarget")]: "getTarget",
[t("actionListTargets")]: "listTargets",
[t("actionUpdateTarget")]: "updateTarget"
},
Role: {
[t('actionCreateRole')]: "createRole",
[t('actionDeleteRole')]: "deleteRole",
[t('actionGetRole')]: "getRole",
[t('actionListRole')]: "listRoles",
[t('actionUpdateRole')]: "updateRole",
[t('actionListAllowedRoleResources')]: "listRoleResources",
[t('actionAddUserRole')]: "addUserRole"
[t("actionCreateRole")]: "createRole",
[t("actionDeleteRole")]: "deleteRole",
[t("actionGetRole")]: "getRole",
[t("actionListRole")]: "listRoles",
[t("actionUpdateRole")]: "updateRole",
[t("actionListAllowedRoleResources")]: "listRoleResources",
[t("actionAddUserRole")]: "addUserRole"
},
"Access Token": {
[t('actionGenerateAccessToken')]: "generateAccessToken",
[t('actionDeleteAccessToken')]: "deleteAcessToken",
[t('actionListAccessTokens')]: "listAccessTokens"
[t("actionGenerateAccessToken")]: "generateAccessToken",
[t("actionDeleteAccessToken")]: "deleteAcessToken",
[t("actionListAccessTokens")]: "listAccessTokens"
},
"Resource Rule": {
@@ -102,36 +105,52 @@ function getActionsCategories(root: boolean) {
}
};
if (env.flags.enableClients) {
actionsByCategory["Clients"] = {
"Create Client": "createClient",
"Delete Client": "deleteClient",
"Update Client": "updateClient",
"List Clients": "listClients",
"Get Client": "getClient"
};
}
if (root) {
actionsByCategory["Organization"] = {
[t('actionListOrgs')]: "listOrgs",
[t('actionCheckOrgId')]: "checkOrgId",
[t('actionCreateOrg')]: "createOrg",
[t('actionDeleteOrg')]: "deleteOrg",
[t('actionListApiKeys')]: "listApiKeys",
[t('actionListApiKeyActions')]: "listApiKeyActions",
[t('actionSetApiKeyActions')]: "setApiKeyActions",
[t('actionCreateApiKey')]: "createApiKey",
[t('actionDeleteApiKey')]: "deleteApiKey",
[t("actionListOrgs")]: "listOrgs",
[t("actionCheckOrgId")]: "checkOrgId",
[t("actionCreateOrg")]: "createOrg",
[t("actionDeleteOrg")]: "deleteOrg",
[t("actionListApiKeys")]: "listApiKeys",
[t("actionListApiKeyActions")]: "listApiKeyActions",
[t("actionSetApiKeyActions")]: "setApiKeyActions",
[t("actionCreateApiKey")]: "createApiKey",
[t("actionDeleteApiKey")]: "deleteApiKey",
...actionsByCategory["Organization"]
};
actionsByCategory["Identity Provider (IDP)"] = {
[t('actionCreateIdp')]: "createIdp",
[t('actionUpdateIdp')]: "updateIdp",
[t('actionDeleteIdp')]: "deleteIdp",
[t('actionListIdps')]: "listIdps",
[t('actionGetIdp')]: "getIdp",
[t('actionCreateIdpOrg')]: "createIdpOrg",
[t('actionDeleteIdpOrg')]: "deleteIdpOrg",
[t('actionListIdpOrgs')]: "listIdpOrgs",
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
[t("actionCreateIdp")]: "createIdp",
[t("actionUpdateIdp")]: "updateIdp",
[t("actionDeleteIdp")]: "deleteIdp",
[t("actionListIdps")]: "listIdps",
[t("actionGetIdp")]: "getIdp",
[t("actionCreateIdpOrg")]: "createIdpOrg",
[t("actionDeleteIdpOrg")]: "deleteIdpOrg",
[t("actionListIdpOrgs")]: "listIdpOrgs",
[t("actionUpdateIdpOrg")]: "updateIdpOrg"
};
actionsByCategory["User"] = {
[t('actionUpdateUser')]: "updateUser",
[t('actionGetUser')]: "getUser"
[t("actionUpdateUser")]: "updateUser",
[t("actionGetUser")]: "getUser"
};
if (build == "saas") {
actionsByCategory["SAAS"] = {
["Send Usage Notification Email"]: "sendUsageNotification",
}
}
}
return actionsByCategory;
@@ -189,7 +208,7 @@ export default function PermissionsSelectBox({
<CheckboxWithLabel
variant="outlinePrimarySquare"
id="toggle-all-permissions"
label={t('permissionsAllowAll')}
label={t("permissionsAllowAll")}
checked={allPermissionsChecked}
onCheckedChange={(checked) =>
toggleAllPermissions(checked as boolean)
@@ -208,7 +227,7 @@ export default function PermissionsSelectBox({
<CheckboxWithLabel
variant="outlinePrimarySquare"
id={`toggle-all-${category}`}
label={t('allowAll')}
label={t("allowAll")}
checked={allChecked}
onCheckedChange={(checked) =>
toggleAllInCategory(

View File

@@ -31,27 +31,23 @@ import {
} from "@app/components/ui/input-otp";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
import {
AuthWithPasswordResponse,
AuthWithWhitelistResponse
} from "@server/routers/resource";
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
import {
resourcePasswordProxy,
resourcePincodeProxy,
resourceWhitelistProxy,
resourceAccessProxy,
resourceAccessProxy
} from "@app/actions/server";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import Image from "next/image";
import BrandingLogo from "@app/components/BrandingLogo";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
const pinSchema = z.object({
pin: z
@@ -90,6 +86,7 @@ type ResourceAuthPortalProps = {
};
redirect: string;
idps?: LoginFormIDP[];
orgId?: string;
};
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
@@ -117,8 +114,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { supporterStatus } = useSupporterStatusContext();
function getDefaultSelectedMethod() {
@@ -216,7 +211,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
console.error(e);
setWhitelistError(
t("otpEmailErrorAuthenticate", {
defaultValue: "An unexpected error occurred. Please try again."
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
@@ -249,7 +245,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
console.error(e);
setPincodeError(
t("pincodeErrorAuthenticate", {
defaultValue: "An unexpected error occurred. Please try again."
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
@@ -282,7 +279,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
console.error(e);
setPasswordError(
t("passwordErrorAuthenticate", {
defaultValue: "An unexpected error occurred. Please try again."
defaultValue:
"An unexpected error occurred. Please try again."
})
);
} finally {
@@ -310,34 +308,75 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
function getTitle() {
if (build !== "oss" && env.branding.resourceAuthPage?.titleText) {
return env.branding.resourceAuthPage.titleText;
}
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
if (build !== "oss" && env.branding.resourceAuthPage?.subtitleText) {
return env.branding.resourceAuthPage.subtitleText
.split("{{resourceName}}")
.join(resourceName);
}
return numMethods > 1
? t("authenticationMethodChoose", { name: props.resource.name })
: t("authenticationRequest", { name: props.resource.name });
? t("authenticationMethodChoose", { name: resourceName })
: t("authenticationRequest", { name: resourceName });
}
return (
<div>
{!accessDenied ? (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "}
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
{build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy && (
<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>
)
) : (
<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"
>
Pangolin
</Link>
</span>
</div>
)}
<Card>
<CardHeader>
{build !== "oss" &&
env.branding?.resourceAuthPage?.showLogo && (
<div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo
height={
env.branding.logo?.authPage
?.height || 100
}
width={
env.branding.logo?.authPage
?.width || 100
}
/>
</div>
)}
<CardTitle>{getTitle()}</CardTitle>
<CardDescription>
{getSubtitle(props.resource.name)}
@@ -544,6 +583,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
<LoginForm
idps={props.idps}
redirect={props.redirect}
orgId={props.orgId}
onLogin={async () =>
await handleSSOAuth()
}

View File

@@ -12,6 +12,7 @@ import {
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import CertificateStatus from "@app/components/private/CertificateStatus";
import { toUnicode } from 'punycode';
type ResourceInfoBoxType = {};
@@ -21,15 +22,13 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
const t = useTranslations();
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
{/* 4 cols because of the certs */}
<InfoSections cols={resource.http && build != "oss" ? 4 : 3}>
{resource.http ? (
<>
<InfoSection>
@@ -118,6 +117,34 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
)} */}
</>
)}
{/* <InfoSection> */}
{/* <InfoSectionTitle>{t('visibility')}</InfoSectionTitle> */}
{/* <InfoSectionContent> */}
{/* <span> */}
{/* {resource.enabled ? t('enabled') : t('disabled')} */}
{/* </span> */}
{/* </InfoSectionContent> */}
{/* </InfoSection> */}
{/* Certificate Status Column */}
{resource.http && resource.domainId && resource.fullDomain && build != "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("certificateStatus", {
defaultValue: "Certificate"
})}
</InfoSectionTitle>
<InfoSectionContent>
<CertificateStatus
orgId={resource.orgId}
domainId={resource.domainId}
fullDomain={resource.fullDomain}
autoFetch={true}
showLabel={false}
polling={true}
/>
</InfoSectionContent>
</InfoSection>
)}
<InfoSection>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -108,7 +108,8 @@ export default function SignupForm({
emailParam
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [loading, setLoading] = useState(false);
@@ -129,7 +130,10 @@ export default function SignupForm({
});
const passwordStrength = calculatePasswordStrength(passwordValue);
const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue;
const doPasswordsMatch =
passwordValue.length > 0 &&
confirmPasswordValue.length > 0 &&
passwordValue === confirmPasswordValue;
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
@@ -192,7 +196,10 @@ export default function SignupForm({
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={58} width={175} />
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
/>
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
@@ -211,8 +218,8 @@ export default function SignupForm({
<FormItem>
<FormLabel>{t("email")}</FormLabel>
<FormControl>
<Input
{...field}
<Input
{...field}
disabled={!!emailParam}
/>
</FormControl>
@@ -227,7 +234,8 @@ export default function SignupForm({
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t("password")}</FormLabel>
{passwordStrength.strength === "strong" && (
{passwordStrength.strength ===
"strong" && (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
@@ -238,115 +246,193 @@ export default function SignupForm({
{...field}
onChange={(e) => {
field.onChange(e);
setPasswordValue(e.target.value);
setPasswordValue(
e.target.value
);
}}
className={cn(
passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500",
passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500"
passwordStrength.strength ===
"strong" &&
"border-green-500 focus-visible:ring-green-500",
passwordStrength.strength ===
"medium" &&
"border-yellow-500 focus-visible:ring-yellow-500",
passwordStrength.strength ===
"weak" &&
passwordValue.length >
0 &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{passwordValue.length > 0 && (
<div className="space-y-3 mt-2">
{/* Password Strength Meter */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">{t("passwordStrength")}</span>
<span className={cn(
"text-sm font-semibold",
passwordStrength.strength === "strong" && "text-green-600 dark:text-green-400",
passwordStrength.strength === "medium" && "text-yellow-600 dark:text-yellow-400",
passwordStrength.strength === "weak" && "text-red-600 dark:text-red-400"
)}>
{t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)}
<span className="text-sm font-medium text-foreground">
{t("passwordStrength")}
</span>
<span
className={cn(
"text-sm font-semibold",
passwordStrength.strength ===
"strong" &&
"text-green-600 dark:text-green-400",
passwordStrength.strength ===
"medium" &&
"text-yellow-600 dark:text-yellow-400",
passwordStrength.strength ===
"weak" &&
"text-red-600 dark:text-red-400"
)}
>
{t(
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
)}
</span>
</div>
<Progress
value={passwordStrength.percentage}
<Progress
value={
passwordStrength.percentage
}
className="h-2"
/>
</div>
{/* Requirements Checklist */}
<div className="bg-muted rounded-lg p-3 space-y-2">
<div className="text-sm font-medium text-foreground mb-2">{t("passwordRequirements")}</div>
<div className="text-sm font-medium text-foreground mb-2">
{t("passwordRequirements")}
</div>
<div className="grid grid-cols-1 gap-1.5">
<div className="flex items-center gap-2">
{passwordStrength.requirements.length ? (
{passwordStrength
.requirements
.length ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.length ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementLengthText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.length
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLengthText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.uppercase ? (
{passwordStrength
.requirements
.uppercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.uppercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementUppercaseText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.uppercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementUppercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.lowercase ? (
{passwordStrength
.requirements
.lowercase ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.lowercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementLowercaseText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.lowercase
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementLowercaseText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.number ? (
{passwordStrength
.requirements
.number ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.number ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementNumberText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.number
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementNumberText"
)}
</span>
</div>
<div className="flex items-center gap-2">
{passwordStrength.requirements.special ? (
{passwordStrength
.requirements
.special ? (
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
"text-sm",
passwordStrength.requirements.special ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
)}>
{t("passwordRequirementSpecialText")}
<span
className={cn(
"text-sm",
passwordStrength
.requirements
.special
? "text-green-600 dark:text-green-400"
: "text-muted-foreground"
)}
>
{t(
"passwordRequirementSpecialText"
)}
</span>
</div>
</div>
</div>
</div>
)}
{/* Only show FormMessage when not showing our custom requirements */}
{passwordValue.length === 0 && <FormMessage />}
{passwordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
@@ -356,7 +442,9 @@ export default function SignupForm({
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>{t('confirmPassword')}</FormLabel>
<FormLabel>
{t("confirmPassword")}
</FormLabel>
{doPasswordsMatch && (
<Check className="h-4 w-4 text-green-500" />
)}
@@ -368,23 +456,32 @@ export default function SignupForm({
{...field}
onChange={(e) => {
field.onChange(e);
setConfirmPasswordValue(e.target.value);
setConfirmPasswordValue(
e.target.value
);
}}
className={cn(
doPasswordsMatch && "border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500"
doPasswordsMatch &&
"border-green-500 focus-visible:ring-green-500",
confirmPasswordValue.length >
0 &&
!doPasswordsMatch &&
"border-red-500 focus-visible:ring-red-500"
)}
autoComplete="new-password"
/>
</div>
</FormControl>
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{confirmPasswordValue.length > 0 &&
!doPasswordsMatch && (
<p className="text-sm text-red-600 mt-1">
{t("passwordsDoNotMatch")}
</p>
)}
{/* Only show FormMessage when field is empty */}
{confirmPasswordValue.length === 0 && <FormMessage />}
{confirmPasswordValue.length === 0 && (
<FormMessage />
)}
</FormItem>
)}
/>
@@ -407,28 +504,32 @@ export default function SignupForm({
</FormControl>
<div className="leading-none">
<FormLabel className="text-sm font-normal">
{t("signUpTerms.IAgreeToThe")}
<a
href="https://digpangolin.com/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
<div>
{t(
"signUpTerms.termsOfService"
)}
</a>
{t("signUpTerms.and")}
<a
href="https://digpangolin.com/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://digpangolin.com/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t("signUpTerms.and")}{" "}
<a
href="https://digpangolin.com/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
@@ -451,4 +552,4 @@ export default function SignupForm({
</CardContent>
</Card>
);
}
}

View File

@@ -29,6 +29,7 @@ import { useTranslations } from "next-intl";
import { parseDataSize } from "@app/lib/dataSize";
import { Badge } from "@app/components/ui/badge";
import { InfoPopup } from "@app/components/ui/info-popup";
import { build } from "@server/build";
export type SiteRow = {
id: number;
@@ -42,6 +43,8 @@ export type SiteRow = {
newtUpdateAvailable?: boolean;
online: boolean;
address?: string;
exitNodeName?: string;
exitNodeEndpoint?: string;
};
type SitesTableProps = {
@@ -280,6 +283,34 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}
}
},
{
accessorKey: "exitNode",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("exitNode")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
return (
<div className="flex items-center space-x-2">
<span>{originalRow.exitNodeName}</span>
{build == "saas" && originalRow.exitNodeName &&
['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && (
<Badge variant="secondary">Cloud</Badge>
)}
</div>
);
},
},
...(env.flags.enableClients ? [{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {

View File

@@ -2,7 +2,6 @@
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -26,6 +25,7 @@ type ValidateOidcTokenParams = {
expectedState: string | undefined;
stateCookie: string | undefined;
idp: { name: string };
loginPageId?: number;
};
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
@@ -44,7 +44,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
async function validate() {
setLoading(true);
console.log(t('idpOidcTokenValidating'), {
console.log(t("idpOidcTokenValidating"), {
code: props.code,
expectedState: props.expectedState,
stateCookie: props.stateCookie
@@ -59,7 +59,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
props.idpId,
props.code || "",
props.expectedState || "",
props.stateCookie || ""
props.stateCookie || "",
props.loginPageId
);
if (response.error) {
@@ -78,7 +79,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const redirectUrl = data.redirectUrl;
if (!redirectUrl) {
router.push("/");
router.push(env.app.dashboardUrl);
}
setLoading(false);
@@ -89,8 +90,13 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
} else {
router.push(data.redirectUrl);
}
} catch (e) {
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
} catch (e: any) {
console.error(e);
setError(
t("idpErrorOidcTokenValidating", {
defaultValue: "An unexpected error occurred. Please try again."
})
);
} finally {
setLoading(false);
}
@@ -103,20 +109,24 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('idpConnectingTo', {name: props.idp.name})}</CardTitle>
<CardDescription>{t('idpConnectingToDescription')}</CardDescription>
<CardTitle>
{t("idpConnectingTo", { name: props.idp.name })}
</CardTitle>
<CardDescription>
{t("idpConnectingToDescription")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{loading && (
<div className="flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin" />
<span>{t('idpConnectingToProcess')}</span>
<span>{t("idpConnectingToProcess")}</span>
</div>
)}
{!loading && !error && (
<div className="flex items-center space-x-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
<span>{t('idpConnectingToFinished')}</span>
<span>{t("idpConnectingToFinished")}</span>
</div>
)}
{error && (
@@ -124,7 +134,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span>
{t('idpErrorConnectingTo', {name: props.idp.name})}
{t("idpErrorConnectingTo", {
name: props.idp.name
})}
</span>
<span className="text-xs">{error}</span>
</AlertDescription>

View File

@@ -0,0 +1,538 @@
/*
* 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, forwardRef, useImperativeHandle } from "react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Label } from "@/components/ui/label";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useRouter } from "next/navigation";
import {
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm
} from "@app/components/Settings";
import { useTranslations } from "next-intl";
import { GetLoginPageResponse } from "@server/routers/private/loginPage";
import { ListDomainsResponse } from "@server/routers/domain";
import { DomainRow } from "@app/components/DomainsTable";
import { toUnicode } from "punycode";
import { Globe, Trash2 } from "lucide-react";
import CertificateStatus from "@app/components/private/CertificateStatus";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext";
import { TierId } from "@server/lib/private/billing/tiers";
import { build } from "@server/build";
// Auth page form schema
const AuthPageFormSchema = z.object({
authPageDomainId: z.string().optional(),
authPageSubdomain: z.string().optional()
});
type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
interface AuthPageSettingsProps {
onSaveSuccess?: () => void;
onSaveError?: (error: any) => void;
}
export interface AuthPageSettingsRef {
saveAuthSettings: () => Promise<void>;
hasUnsavedChanges: () => boolean;
}
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(({
onSaveSuccess,
onSaveError
}, ref) => {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const subscription = usePrivateSubscriptionStatusContext();
const subscribed = subscription?.getTier() === TierId.STANDARD;
// Auth page domain state
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
null
);
const [loginPageExists, setLoginPageExists] = useState(false);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
// Expose save function to parent component
useImperativeHandle(ref, () => ({
saveAuthSettings: async () => {
await form.handleSubmit(onSubmit)();
},
hasUnsavedChanges: () => hasUnsavedChanges
}), [form, hasUnsavedChanges]);
// Fetch login page and domains data
useEffect(() => {
if (build !== "saas") {
return;
}
const fetchLoginPage = async () => {
try {
const res = await api.get<AxiosResponse<GetLoginPageResponse>>(
`/org/${org?.org.orgId}/login-page`
);
if (res.status === 200) {
setLoginPage(res.data.data);
setLoginPageExists(true);
// Update form with login page data
form.setValue(
"authPageDomainId",
res.data.data.domainId || ""
);
form.setValue(
"authPageSubdomain",
res.data.data.subdomain || ""
);
}
} catch (err) {
// Login page doesn't exist yet, that's okay
setLoginPage(null);
setLoginPageExists(false);
} finally {
setLoadingLoginPage(false);
}
};
const fetchDomains = async () => {
try {
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
`/org/${org?.org.orgId}/domains/`
);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (org?.org.orgId) {
fetchLoginPage();
fetchDomains();
}
}, []);
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(true);
}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
async function onSubmit(data: AuthPageFormValues) {
setLoadingSave(true);
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (build !== "saas" || (build === "saas" && subscribed)) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
}
}
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(e, t("authPageErrorUpdateMessage"))
});
onSaveError?.(e);
} finally {
setLoadingSave(false);
}
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPage")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<SettingsSectionForm>
{loadingLoginPage ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t("domainNotFound")}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t("noDomainSet");
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(true)
}
>
{form.watch("authPageDomainId")
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch("authPageDomainId") && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={
clearAuthPageDomain
}
>
<Trash2 size="14" />
</Button>
)}
</div>
</div>
{/* Certificate Status */}
{(build !== "saas" ||
(build === "saas" && subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={org?.org.orgId || ""}
domainId={loginPage.domainId}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
)}
{!form.watch("authPageDomainId") && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
</div>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
});
AuthPageSettings.displayName = 'AuthPageSettings';
export default AuthPageSettings;

View File

@@ -0,0 +1,185 @@
/*
* 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 {
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage
} from "@app/components/ui/form";
import { SwitchInput } from "@app/components/SwitchInput";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import { Control, FieldValues, Path } from "react-hook-form";
type Role = {
roleId: number;
name: string;
};
type AutoProvisionConfigWidgetProps<T extends FieldValues> = {
control: Control<T>;
autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void;
roleMappingMode: "role" | "expression";
onRoleMappingModeChange: (mode: "role" | "expression") => void;
roles: Role[];
roleIdFieldName: Path<T>;
roleMappingFieldName: Path<T>;
};
export default function AutoProvisionConfigWidget<T extends FieldValues>({
control,
autoProvision,
onAutoProvisionChange,
roleMappingMode,
onRoleMappingModeChange,
roles,
roleIdFieldName,
roleMappingFieldName
}: AutoProvisionConfigWidgetProps<T>) {
const t = useTranslations();
return (
<div className="space-y-4">
<div className="mb-4">
<SwitchInput
id="auto-provision-toggle"
label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange}
/>
<span className="text-sm text-muted-foreground">
{t("idpAutoProvisionUsersDescription")}
</span>
</div>
{autoProvision && (
<div className="space-y-4">
<div>
<FormLabel className="mb-2">
{t("roleMapping")}
</FormLabel>
<FormDescription className="mb-4">
{t("roleMappingDescription")}
</FormDescription>
<RadioGroup
value={roleMappingMode}
onValueChange={onRoleMappingModeChange}
className="flex space-x-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="role" id="role-mode" />
<label
htmlFor="role-mode"
className="text-sm font-medium"
>
{t("selectRole")}
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="expression"
id="expression-mode"
/>
<label
htmlFor="expression-mode"
className="text-sm font-medium"
>
{t("roleMappingExpression")}
</label>
</div>
</RadioGroup>
</div>
{roleMappingMode === "role" ? (
<FormField
control={control}
name={roleIdFieldName}
render={({ field }) => (
<FormItem>
<Select
onValueChange={(value) =>
field.onChange(Number(value))
}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectRolePlaceholder"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("selectRoleDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={control}
name={roleMappingFieldName}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
defaultValue={field.value || ""}
value={field.value || ""}
placeholder={t(
"roleMappingExpressionPlaceholder"
)}
/>
</FormControl>
<FormDescription>
{t("roleMappingExpressionDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,156 @@
/*
* 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 "@/components/ui/button";
import { RotateCw } from "lucide-react";
import { useCertificate } from "@app/hooks/privateUseCertificate";
import { useTranslations } from "next-intl";
type CertificateStatusProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
showLabel?: boolean;
className?: string;
onRefresh?: () => void;
polling?: boolean;
pollingInterval?: number;
};
export default function CertificateStatus({
orgId,
domainId,
fullDomain,
autoFetch = true,
showLabel = true,
className = "",
onRefresh,
polling = false,
pollingInterval = 5000
}: CertificateStatusProps) {
const t = useTranslations();
const { cert, certLoading, certError, refreshing, refreshCert } = useCertificate({
orgId,
domainId,
fullDomain,
autoFetch,
polling,
pollingInterval
});
const handleRefresh = async () => {
await refreshCert();
onRefresh?.();
};
const getStatusColor = (status: string) => {
switch (status) {
case "valid":
return "text-green-500";
case "pending":
case "requested":
return "text-yellow-500";
case "expired":
case "failed":
return "text-red-500";
default:
return "text-muted-foreground";
}
};
const shouldShowRefreshButton = (status: string, updatedAt: string) => {
return (
status === "failed" ||
status === "expired" ||
(status === "requested" &&
updatedAt && new Date(updatedAt).getTime() < Date.now() - 5 * 60 * 1000)
);
};
if (certLoading) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-muted-foreground">
{t("loading")}
</span>
</div>
);
}
if (certError) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-red-500">
{certError}
</span>
</div>
);
}
if (!cert) {
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className="text-sm text-muted-foreground">
{t("none", { defaultValue: "None" })}
</span>
</div>
);
}
return (
<div className={`flex items-center gap-2 ${className}`}>
{showLabel && (
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
)}
<span className={`text-sm ${getStatusColor(cert.status)}`}>
<span className="inline-flex items-center">
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
{shouldShowRefreshButton(cert.status, cert.updatedAt) && (
<Button
size="icon"
variant="ghost"
className="ml-2 p-0 h-auto align-middle"
onClick={handleRefresh}
disabled={refreshing}
title={t("restartCertificate", { defaultValue: "Restart Certificate" })}
>
<RotateCw
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
)}
</span>
</span>
</div>
);
}

View File

@@ -0,0 +1,135 @@
/*
* 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 { useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { generateOidcUrlProxy, type GenerateOidcUrlResponse } from "@app/actions/server";
import { redirect as redirectTo } from "next/navigation";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
};
type IdpLoginButtonsProps = {
idps: LoginFormIDP[];
redirect?: string;
orgId?: string;
};
export default function IdpLoginButtons({
idps,
redirect,
orgId
}: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const t = useTranslations();
async function loginWithIdp(idpId: number) {
setLoading(true);
setError(null);
let redirectToUrl: string | undefined;
try {
const response = await generateOidcUrlProxy(
idpId,
redirect || "/",
orgId
);
if (response.error) {
setError(response.message);
setLoading(false);
return;
}
const data = response.data;
console.log("Redirecting to:", data?.redirectUrl);
if (data?.redirectUrl) {
redirectToUrl = data.redirectUrl;
}
} catch (e: any) {
console.error(e);
setError(
t("loginError", {
defaultValue: "An unexpected error occurred. Please try again."
})
);
setLoading(false);
}
if (redirectToUrl) {
redirectTo(redirectToUrl);
}
}
if (!idps || idps.length === 0) {
return null;
}
return (
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
{idps.map((idp) => {
const effectiveType = idp.variant || idp.name.toLowerCase();
return (
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
disabled={loading}
>
{effectiveType === "google" && (
<Image
src="/idp/google.png"
alt="Google"
width={16}
height={16}
className="rounded"
/>
)}
{effectiveType === "azure" && (
<Image
src="/idp/azure.png"
alt="Azure"
width={16}
height={16}
className="rounded"
/>
)}
<span>{idp.name}</span>
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
/*
* 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[];
onAdd?: () => void;
}
export function IdpDataTable<TData, TValue>({
columns,
data,
onAdd
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="idp-table"
title={t("idp")}
searchPlaceholder={t("idpSearch")}
searchColumn="name"
addButtonText={t("idpAdd")}
onAdd={onAdd}
/>
);
}

View File

@@ -0,0 +1,219 @@
/*
* 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 { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useState } 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 { useRouter } from "next/navigation";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
export type IdpRow = {
idpId: number;
name: string;
type: string;
variant?: string;
};
type Props = {
idps: IdpRow[];
orgId: string;
};
export default function IdpTable({ idps, orgId }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const deleteIdp = async (idpId: number) => {
try {
await api.delete(`/org/${orgId}/idp/${idpId}`);
toast({
title: t("success"),
description: t("idpDeletedDescription")
});
setIsDeleteModalOpen(false);
router.refresh();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const columns: ColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
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: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
const variant = row.original.variant;
return (
<IdpTypeBadge type={type} variant={variant} />
);
}
},
{
id: "actions",
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedIdp(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedIdp && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedIdp(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("idpQuestionRemove", {
name: selectedIdp.name
})}
</p>
<p>
<b>{t("idpMessageRemove")}</b>
</p>
<p>{t("idpMessageConfirm")}</p>
</div>
}
buttonText={t("idpConfirmDelete")}
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
string={selectedIdp.name}
title={t("idpDelete")}
/>
)}
<IdpDataTable
columns={columns}
data={idps}
onAdd={() => router.push(`/${orgId}/settings/idp/create`)}
/>
</>
);
}

View File

@@ -0,0 +1,101 @@
/*
* 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 { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useTranslations } from "next-intl";
type Region = {
value: string;
label: string;
flag: string;
};
const regions: Region[] = [
{
value: "us",
label: "North America",
flag: ""
},
{
value: "eu",
label: "Europe",
flag: ""
}
];
export default function RegionSelector() {
const [selectedRegion, setSelectedRegion] = useState<string>("us");
const t = useTranslations();
const handleRegionChange = (value: string) => {
setSelectedRegion(value);
const region = regions.find((r) => r.value === value);
if (region) {
console.log(`Selected region: ${region.label}`);
}
};
return (
<div className="flex flex-col items-center space-y-2">
<label className="flex items-center gap-1 text-sm font-medium text-muted-foreground">
{t('regionSelectorTitle')}
<InfoPopup info={t('regionSelectorInfo')} />
</label>
<Select value={selectedRegion} onValueChange={handleRegionChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={t('regionSelectorPlaceholder')} />
</SelectTrigger>
<SelectContent>
{regions.map((region) => (
<SelectItem
key={region.value}
value={region.value}
disabled={region.value === "eu"}
>
<div className="flex items-center space-x-2">
<span className="text-lg">{region.flag}</span>
<div className="flex flex-col">
<span
className={
region.value === "eu"
? "text-muted-foreground"
: ""
}
>
{region.label}
</span>
{region.value === "eu" && (
<span className="text-xs text-muted-foreground">
{t('regionSelectorComingSoon')}
</span>
)}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,57 @@
/*
* 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 { useEnvContext } from "@app/hooks/useEnvContext";
import { usePathname } from "next/navigation";
import Image from "next/image";
type SplashImageProps = {
children: React.ReactNode;
};
export default function SplashImage({ children }: SplashImageProps) {
const pathname = usePathname();
const { env } = useEnvContext();
function showBackgroundImage() {
if (!env.branding.background_image_path) {
return false;
}
const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource"];
for (const prefix of pathsPrefixes) {
if (pathname.startsWith(prefix)) {
return true;
}
}
return false;
}
return (
<>
{showBackgroundImage() && (
<Image
src={env.branding.background_image_path!}
alt="Background"
layout="fill"
objectFit="cover"
quality={100}
className="-z-10"
/>
)}
{children}
</>
);
}

View File

@@ -0,0 +1,84 @@
/*
* 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 { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { TransferSessionResponse } from "@server/routers/auth/privateTransferSession";
type ValidateSessionTransferTokenParams = {
token: string;
};
export default function ValidateSessionTransferToken(
props: ValidateSessionTransferTokenParams
) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const t = useTranslations();
useEffect(() => {
async function validate() {
setLoading(true);
let doRedirect = false;
try {
const res = await api.post<
AxiosResponse<TransferSessionResponse>
>(`/auth/transfer-session-token`, {
token: props.token
});
if (res && res.status === 200) {
doRedirect = true;
}
} catch (e) {
console.error(e);
setError(formatAxiosError(e, "Failed to validate token"));
} finally {
setLoading(false);
}
if (doRedirect) {
redirect(env.app.dashboardUrl);
}
}
validate();
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
{error && (
<Alert variant="destructive" className="w-full">
<AlertCircle className="h-5 w-5" />
<AlertDescription className="flex flex-col space-y-2">
<span className="text-xs">{error}</span>
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -14,7 +14,9 @@ const alertVariants = cva(
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500"
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500",
warning:
"border-yellow-500/50 border bg-yellow-500/10 text-yellow-500 dark:border-yellow-400 [&>svg]:text-yellow-500"
}
},
defaultVariants: {

View File

@@ -9,10 +9,13 @@ import {
ToastTitle,
ToastViewport
} from "@/components/ui/toast";
import { useEnvContext } from "@app/hooks/useEnvContext";
export function Toaster() {
const { toasts } = useToast();
const { env } = useEnvContext();
return (
<ToastProvider>
{toasts.map(function ({

View File

@@ -0,0 +1,24 @@
/*
* 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 { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode";
import { createContext } from "react";
type RemoteExitNodeContextType = {
remoteExitNode: GetRemoteExitNodeResponse;
updateRemoteExitNode: (updatedRemoteExitNode: Partial<GetRemoteExitNodeResponse>) => void;
};
const RemoteExitNodeContext = createContext<RemoteExitNodeContextType | undefined>(undefined);
export default RemoteExitNodeContext;

View File

@@ -0,0 +1,28 @@
/*
* 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 { GetOrgSubscriptionResponse } from "@server/routers/private/billing";
import { createContext } from "react";
type SubscriptionStatusContextType = {
subscriptionStatus: GetOrgSubscriptionResponse | null;
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
isActive: () => boolean;
getTier: () => string | null;
};
const PrivateSubscriptionStatusContext = createContext<
SubscriptionStatusContextType | undefined
>(undefined);
export default PrivateSubscriptionStatusContext;

View File

@@ -0,0 +1,135 @@
/*
* 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 { useState, useCallback, useEffect } from "react";
import { AxiosResponse } from "axios";
import { GetCertificateResponse } from "@server/routers/private/certificates";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
type UseCertificateProps = {
orgId: string;
domainId: string;
fullDomain: string;
autoFetch?: boolean;
polling?: boolean;
pollingInterval?: number;
};
type UseCertificateReturn = {
cert: GetCertificateResponse | null;
certLoading: boolean;
certError: string | null;
refreshing: boolean;
fetchCert: () => Promise<void>;
refreshCert: () => Promise<void>;
clearCert: () => void;
};
export function useCertificate({
orgId,
domainId,
fullDomain,
autoFetch = true,
polling = false,
pollingInterval = 5000
}: UseCertificateProps): UseCertificateReturn {
const api = createApiClient(useEnvContext());
const [cert, setCert] = useState<GetCertificateResponse | null>(null);
const [certLoading, setCertLoading] = useState(false);
const [certError, setCertError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetchCert = useCallback(async (showLoading = true) => {
if (!orgId || !domainId || !fullDomain) return;
if (showLoading) {
setCertLoading(true);
}
setCertError(null);
try {
const res = await api.get<AxiosResponse<GetCertificateResponse>>(
`/org/${orgId}/certificate/${domainId}/${fullDomain}`
);
const certData = res.data.data;
if (certData) {
setCert(certData);
}
} catch (error: any) {
console.error("Failed to fetch certificate:", error);
setCertError("Failed to fetch certificate");
} finally {
if (showLoading) {
setCertLoading(false);
}
}
}, [api, orgId, domainId, fullDomain]);
const refreshCert = useCallback(async () => {
if (!cert) return;
setRefreshing(true);
setCertError(null);
try {
await api.post(
`/org/${orgId}/certificate/${cert.certId}/restart`,
{}
);
// Update status to pending
setTimeout(() => {
setCert({ ...cert, status: "pending" });
}, 500);
} catch (error: any) {
console.error("Failed to restart certificate:", error);
setCertError("Failed to restart certificate");
} finally {
setRefreshing(false);
}
}, [api, orgId, cert]);
const clearCert = useCallback(() => {
setCert(null);
setCertError(null);
}, []);
// Auto-fetch on mount if enabled
useEffect(() => {
if (autoFetch && orgId && domainId && fullDomain) {
fetchCert();
}
}, [autoFetch, orgId, domainId, fullDomain, fetchCert]);
// Polling effect
useEffect(() => {
if (!polling || !orgId || !domainId || !fullDomain) return;
const interval = setInterval(() => {
fetchCert(false); // Don't show loading for polling
}, pollingInterval);
return () => clearInterval(interval);
}, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]);
return {
cert,
certLoading,
certError,
refreshing,
fetchCert,
refreshCert,
clearCert
};
}

View File

@@ -0,0 +1,29 @@
/*
* 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 RemoteExitNodeContext from "@app/contexts/privateRemoteExitNodeContext";
import { build } from "@server/build";
import { useContext } from "react";
export function useRemoteExitNodeContext() {
if (build == "oss") {
return null;
}
const context = useContext(RemoteExitNodeContext);
if (context === undefined) {
throw new Error("useRemoteExitNodeContext must be used within a RemoteExitNodeProvider");
}
return context;
}

View File

@@ -0,0 +1,29 @@
/*
* 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 PrivateSubscriptionStatusContext from "@app/contexts/privateSubscriptionStatusContext";
import { build } from "@server/build";
import { useContext } from "react";
export function usePrivateSubscriptionStatusContext() {
if (build == "oss") {
return null;
}
const context = useContext(PrivateSubscriptionStatusContext);
if (context === undefined) {
throw new Error(
"usePrivateSubscriptionStatusContext must be used within an PrivateSubscriptionStatusProvider"
);
}
return context;
}

View File

@@ -0,0 +1,87 @@
/*
* 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.
*/
const defaultTheme = {
light: {
background: "oklch(0.99 0 0)",
foreground: "oklch(0.141 0.005 285.823)",
card: "oklch(1 0 0)",
"card-foreground": "oklch(0.141 0.005 285.823)",
popover: "oklch(1 0 0)",
"popover-foreground": "oklch(0.141 0.005 285.823)",
primary: "oklch(0.6717 0.1946 41.93)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
secondary: "oklch(0.967 0.001 286.375)",
"secondary-foreground": "oklch(0.21 0.006 285.885)",
muted: "oklch(0.967 0.001 286.375)",
"muted-foreground": "oklch(0.552 0.016 285.938)",
accent: "oklch(0.967 0.001 286.375)",
"accent-foreground": "oklch(0.21 0.006 285.885)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.92 0.004 286.32)",
input: "oklch(0.92 0.004 286.32)",
ring: "oklch(0.705 0.213 47.604)",
radius: "0.65rem",
"chart-1": "oklch(0.646 0.222 41.116)",
"chart-2": "oklch(0.6 0.118 184.704)",
"chart-3": "oklch(0.398 0.07 227.392)",
"chart-4": "oklch(0.828 0.189 84.429)",
"chart-5": "oklch(0.769 0.188 70.08)"
},
dark: {
background: "oklch(0.20 0.006 285.885)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.21 0.006 285.885)",
"card-foreground": "oklch(0.985 0 0)",
popover: "oklch(0.21 0.006 285.885)",
"popover-foreground": "oklch(0.985 0 0)",
primary: "oklch(0.6717 0.1946 41.93)",
"primary-foreground": "oklch(0.98 0.016 73.684)",
secondary: "oklch(0.274 0.006 286.033)",
"secondary-foreground": "oklch(0.985 0 0)",
muted: "oklch(0.274 0.006 286.033)",
"muted-foreground": "oklch(0.705 0.015 286.067)",
accent: "oklch(0.274 0.006 286.033)",
"accent-foreground": "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.646 0.222 41.116)",
"chart-1": "oklch(0.488 0.243 264.376)",
"chart-2": "oklch(0.696 0.17 162.48)",
"chart-3": "oklch(0.769 0.188 70.08)",
"chart-4": "oklch(0.627 0.265 303.9)",
"chart-5": "oklch(0.645 0.246 16.439)"
}
};
export default function setGlobalColorTheme(
themeMode: "light" | "dark",
colors: {
light: Record<string, string>;
dark: Record<string, string>;
}
) {
const merged = {
light: { ...defaultTheme.light, ...colors.light },
dark: { ...defaultTheme.dark, ...colors.dark }
};
const theme = merged[themeMode] as {
[key: string]: string;
};
for (const key in theme) {
document.documentElement.style.setProperty(`--${key}`, theme[key]);
}
}

View File

@@ -13,10 +13,13 @@ export function pullEnv(): Env {
resourceAccessTokenHeadersId: process.env
.RESOURCE_ACCESS_TOKEN_HEADERS_ID as string,
resourceAccessTokenHeadersToken: process.env
.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string
.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string,
reoClientId: process.env.REO_CLIENT_ID as string,
maxmind_db_path: process.env.MAXMIND_DB_PATH as string
},
app: {
environment: process.env.ENVIRONMENT as string,
sandbox_mode: process.env.SANDBOX_MODE === "true" ? true : false,
version: process.env.APP_VERSION as string,
dashboardUrl: process.env.DASHBOARD_URL as string
},
@@ -47,5 +50,52 @@ export function pullEnv(): Env {
hideSupporterKey:
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false
},
branding: {
appName: process.env.BRANDING_APP_NAME as string,
background_image_path: process.env.BACKGROUND_IMAGE_PATH as string,
logo: {
lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string,
darkPath: process.env.BRANDING_LOGO_DARK_PATH as string,
authPage: {
width: parseInt(
process.env.BRANDING_LOGO_AUTH_WIDTH as string
),
height: parseInt(
process.env.BRANDING_LOGO_AUTH_HEIGHT as string
)
},
navbar: {
width: parseInt(
process.env.BRANDING_LOGO_NAVBAR_WIDTH as string
),
height: parseInt(
process.env.BRANDING_LOGO_NAVBAR_HEIGHT as string
)
}
},
loginPage: {
titleText: process.env.LOGIN_PAGE_TITLE_TEXT as string,
subtitleText: process.env.LOGIN_PAGE_SUBTITLE_TEXT as string
},
signupPage: {
titleText: process.env.SIGNUP_PAGE_TITLE_TEXT as string,
subtitleText: process.env.SIGNUP_PAGE_SUBTITLE_TEXT as string
},
resourceAuthPage: {
showLogo:
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO === "true"
? true
: false,
hidePoweredBy:
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY === "true"
? true
: false,
titleText: process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT as string,
subtitleText: process.env
.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT as string
},
footer: process.env.BRANDING_FOOTER as string
}
};
}

View File

@@ -1,6 +1,7 @@
export type Env = {
app: {
environment: string;
sandbox_mode: boolean;
version: string;
dashboardUrl: string;
};
@@ -12,6 +13,8 @@ export type Env = {
resourceSessionRequestParam: string;
resourceAccessTokenHeadersId: string;
resourceAccessTokenHeadersToken: string;
reoClientId?: string;
maxmind_db_path?: string;
};
email: {
emailEnabled: boolean;
@@ -25,5 +28,36 @@ export type Env = {
disableBasicWireguardSites: boolean;
enableClients: boolean;
hideSupporterKey: boolean;
}
},
branding: {
appName?: string;
background_image_path?: string;
logo?: {
lightPath?: string;
darkPath?: string;
authPage?: {
width?: number;
height?: number;
};
navbar?: {
width?: number;
height?: number;
}
},
loginPage?: {
titleText?: string;
subtitleText?: string;
},
signupPage?: {
titleText?: string;
subtitleText?: string;
},
resourceAuthPage?: {
showLogo?: boolean;
hidePoweredBy?: boolean;
titleText?: string;
subtitleText?: string;
},
footer?: string;
};
};

View File

@@ -0,0 +1,13 @@
/*
* 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.
*/

42
src/middleware.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { build } from '@server/build';
export function middleware(request: NextRequest) {
// If build is OSS, block access to private routes
if (build === 'oss') {
const pathname = request.nextUrl.pathname;
// Define private route patterns that should be blocked in OSS build
const privateRoutes = [
'/settings/billing',
'/settings/remote-exit-nodes',
'/settings/idp',
'/auth/org'
];
// Check if current path matches any private route pattern
const isPrivateRoute = privateRoutes.some(route =>
pathname.includes(route)
);
if (isPrivateRoute) {
// Return 404 to make it seem like the route doesn't exist
return new NextResponse(null, { status: 404 });
}
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};

View File

@@ -0,0 +1,56 @@
/*
* 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 RemoteExitNodeContext from "@app/contexts/privateRemoteExitNodeContext";
import { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode";
import { useState } from "react";
import { useTranslations } from "next-intl";
type RemoteExitNodeProviderProps = {
children: React.ReactNode;
remoteExitNode: GetRemoteExitNodeResponse;
};
export function RemoteExitNodeProvider({
children,
remoteExitNode: serverRemoteExitNode
}: RemoteExitNodeProviderProps) {
const [remoteExitNode, setRemoteExitNode] = useState<GetRemoteExitNodeResponse>(serverRemoteExitNode);
const t = useTranslations();
const updateRemoteExitNode = (updatedRemoteExitNode: Partial<GetRemoteExitNodeResponse>) => {
if (!remoteExitNode) {
throw new Error(t('remoteExitNodeErrorNoUpdate'));
}
setRemoteExitNode((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updatedRemoteExitNode
};
});
};
return (
<RemoteExitNodeContext.Provider value={{ remoteExitNode, updateRemoteExitNode }}>
{children}
</RemoteExitNodeContext.Provider>
);
}
export default RemoteExitNodeProvider;

View File

@@ -0,0 +1,84 @@
/*
* 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 PrivateSubscriptionStatusContext from "@app/contexts/privateSubscriptionStatusContext";
import { getTierPriceSet } from "@server/lib/private/billing/tiers";
import { GetOrgSubscriptionResponse } from "@server/routers/private/billing";
import { useState } from "react";
interface ProviderProps {
children: React.ReactNode;
subscriptionStatus: GetOrgSubscriptionResponse | null;
env: string;
sandbox_mode: boolean;
}
export function PrivateSubscriptionStatusProvider({
children,
subscriptionStatus,
env,
sandbox_mode
}: ProviderProps) {
const [subscriptionStatusState, setSubscriptionStatusState] =
useState<GetOrgSubscriptionResponse | null>(subscriptionStatus);
const updateSubscriptionStatus = (updatedSubscriptionStatus: GetOrgSubscriptionResponse) => {
setSubscriptionStatusState((prev) => {
return {
...updatedSubscriptionStatus
};
});
};
const isActive = () => {
if (subscriptionStatus?.subscription?.status === "active") {
return true;
}
return false;
};
const getTier = () => {
const tierPriceSet = getTierPriceSet(env, sandbox_mode);
if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) {
// Iterate through tiers in order (earlier keys are higher tiers)
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
// Check if any subscription item matches this tier's price ID
const matchingItem = subscriptionStatus.items.find(item => item.priceId === priceId);
if (matchingItem) {
return tierId;
}
}
}
console.log("No matching tier found");
return null;
};
return (
<PrivateSubscriptionStatusContext.Provider
value={{
subscriptionStatus: subscriptionStatusState,
updateSubscriptionStatus,
isActive,
getTier
}}
>
{children}
</PrivateSubscriptionStatusContext.Provider>
);
}
export default PrivateSubscriptionStatusProvider;

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.
*/
"use client";
import setGlobalColorTheme from "@app/lib/privateThemeColors";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
type ThemeColorStateProps = {
children: React.ReactNode;
colors: any;
};
export default function ThemeDataProvider({
children,
colors
}: ThemeColorStateProps) {
const [isMounted, setIsMounted] = useState(false);
const { theme } = useTheme();
useEffect(() => {
if (!colors) {
setIsMounted(true);
return;
}
let lightOrDark = theme;
if (theme === "system" || !theme) {
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
}
setGlobalColorTheme(lightOrDark as "light" | "dark", colors);
if (!isMounted) {
setIsMounted(true);
}
}, [theme]);
if (!isMounted) {
return null;
}
return <>{children}</>;
}