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

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