mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-18 10:56:38 +00:00
Chungus
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
97
src/app/[orgId]/settings/(private)/billing/layout.tsx
Normal file
97
src/app/[orgId]/settings/(private)/billing/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
767
src/app/[orgId]/settings/(private)/billing/page.tsx
Normal file
767
src/app/[orgId]/settings/(private)/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
996
src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
Normal file
996
src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx
Normal file
63
src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx
Normal file
21
src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx
Normal 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`);
|
||||
}
|
||||
870
src/app/[orgId]/settings/(private)/idp/create/page.tsx
Normal file
870
src/app/[orgId]/settings/(private)/idp/create/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/app/[orgId]/settings/(private)/idp/page.tsx
Normal file
81
src/app/[orgId]/settings/(private)/idp/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 <></>;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: ""
|
||||
};
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
193
src/app/auth/(private)/org/page.tsx
Normal file
193
src/app/auth/(private)/org/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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: ""
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: ""
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user