mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-23 09:44:09 +00:00
Chungus
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { cookies, headers as reqHeaders } from "next/headers";
|
||||
import { ResponseT } from "@server/types/Response";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
@@ -14,7 +14,10 @@ type CookieOptions = {
|
||||
domain?: string;
|
||||
};
|
||||
|
||||
function parseSetCookieString(setCookie: string): {
|
||||
function parseSetCookieString(
|
||||
setCookie: string,
|
||||
host?: string
|
||||
): {
|
||||
name: string;
|
||||
value: string;
|
||||
options: CookieOptions;
|
||||
@@ -50,16 +53,11 @@ function parseSetCookieString(setCookie: string): {
|
||||
case "max-age":
|
||||
options.maxAge = parseInt(v, 10);
|
||||
break;
|
||||
case "domain":
|
||||
options.domain = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.domain) {
|
||||
const d = env.app.dashboardUrl
|
||||
? "." + new URL(env.app.dashboardUrl).hostname
|
||||
: undefined;
|
||||
const d = host ? host : new URL(env.app.dashboardUrl).hostname;
|
||||
if (d) {
|
||||
options.domain = d;
|
||||
}
|
||||
@@ -78,6 +76,9 @@ async function makeApiRequest<T>(
|
||||
const allCookies = await cookies();
|
||||
const cookieHeader = allCookies.toString();
|
||||
|
||||
const headersList = await reqHeaders();
|
||||
const host = headersList.get("host");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": "x-csrf-protection",
|
||||
@@ -107,7 +108,10 @@ async function makeApiRequest<T>(
|
||||
const rawSetCookie = res.headers.get("set-cookie");
|
||||
if (rawSetCookie) {
|
||||
try {
|
||||
const { name, value, options } = parseSetCookieString(rawSetCookie);
|
||||
const { name, value, options } = parseSetCookieString(
|
||||
rawSetCookie,
|
||||
host || undefined
|
||||
);
|
||||
const allCookies = await cookies();
|
||||
allCookies.set(name, value, options);
|
||||
} catch (cookieError) {
|
||||
|
||||
@@ -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: ""
|
||||
};
|
||||
|
||||
|
||||
@@ -21,12 +21,14 @@ type AutoLoginHandlerProps = {
|
||||
resourceId: number;
|
||||
skipToIdpId: number;
|
||||
redirectUrl: string;
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
export default function AutoLoginHandler({
|
||||
resourceId,
|
||||
skipToIdpId,
|
||||
redirectUrl
|
||||
redirectUrl,
|
||||
orgId
|
||||
}: AutoLoginHandlerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
@@ -44,7 +46,8 @@ export default function AutoLoginHandler({
|
||||
try {
|
||||
const response = await generateOidcUrlProxy(
|
||||
skipToIdpId,
|
||||
redirectUrl
|
||||
redirectUrl,
|
||||
orgId
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
@@ -83,7 +86,9 @@ export default function AutoLoginHandler({
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("autoLoginTitle")}</CardTitle>
|
||||
<CardDescription>{t("autoLoginDescription")}</CardDescription>
|
||||
<CardDescription>
|
||||
{t("autoLoginDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
|
||||
@@ -27,10 +27,12 @@ export default function BrandingLogo(props: BrandingLogoProps) {
|
||||
}
|
||||
|
||||
if (lightOrDark === "light") {
|
||||
return "/logo/word_mark_black.png";
|
||||
return (
|
||||
env.branding.logo?.lightPath || "/logo/word_mark_black.png"
|
||||
);
|
||||
}
|
||||
|
||||
return "/logo/word_mark_white.png";
|
||||
return env.branding.logo?.darkPath || "/logo/word_mark_white.png";
|
||||
}
|
||||
|
||||
const path = getPath();
|
||||
|
||||
@@ -38,7 +38,10 @@ export default function DashboardLoginForm({
|
||||
<Card className="shadow-md w-full max-w-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={58} width={175} />
|
||||
<BrandingLogo
|
||||
height={env.branding.logo?.authPage?.height || 58}
|
||||
width={env.branding.logo?.authPage?.width || 175}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createApiClient, formatAxiosError } from "@/lib/api";
|
||||
import { useEnvContext } from "@/hooks/useEnvContext";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
|
||||
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/privateCheckDomainNamespaceAvailability";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -155,7 +156,10 @@ export default function DomainPicker2({
|
||||
fullDomain: firstOrgDomain.baseDomain,
|
||||
baseDomain: firstOrgDomain.baseDomain
|
||||
});
|
||||
} else if ((build === "saas" || build === "enterprise") && !hideFreeDomain) {
|
||||
} else if (
|
||||
(build === "saas" || build === "enterprise") &&
|
||||
!hideFreeDomain
|
||||
) {
|
||||
// If no organization domains, select the provided domain option
|
||||
const domainOptionText =
|
||||
build === "enterprise"
|
||||
@@ -198,7 +202,21 @@ export default function DomainPicker2({
|
||||
.toLowerCase()
|
||||
.replace(/\./g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
.replace(/-+/g, "-");
|
||||
.replace(/-+/g, "-") // Replace multiple consecutive dashes with single dash
|
||||
.replace(/^-|-$/g, ""); // Remove leading/trailing dashes
|
||||
|
||||
if (build != "oss") {
|
||||
const response = await api.get<
|
||||
AxiosResponse<CheckDomainAvailabilityResponse>
|
||||
>(
|
||||
`/domain/check-namespace-availability?subdomain=${encodeURIComponent(checkSubdomain)}`
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
const { options } = response.data.data;
|
||||
setAvailableOptions(options);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check domain availability:", error);
|
||||
setAvailableOptions([]);
|
||||
@@ -272,13 +290,16 @@ export default function DomainPicker2({
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("domainPickerInvalidSubdomain"),
|
||||
description: t("domainPickerInvalidSubdomainRemoved", { sub }),
|
||||
description: t("domainPickerInvalidSubdomainRemoved", { sub })
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
const ok = validateByDomainType(sanitized, {
|
||||
type: base.type === "provided-search" ? "provided-search" : "organization",
|
||||
type:
|
||||
base.type === "provided-search"
|
||||
? "provided-search"
|
||||
: "organization",
|
||||
domainType: base.domainType
|
||||
});
|
||||
|
||||
@@ -286,7 +307,10 @@ export default function DomainPicker2({
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("domainPickerInvalidSubdomain"),
|
||||
description: t("domainPickerInvalidSubdomainCannotMakeValid", { sub, domain: base.domain }),
|
||||
description: t("domainPickerInvalidSubdomainCannotMakeValid", {
|
||||
sub,
|
||||
domain: base.domain
|
||||
})
|
||||
});
|
||||
return "";
|
||||
}
|
||||
@@ -294,7 +318,10 @@ export default function DomainPicker2({
|
||||
if (sub !== sanitized) {
|
||||
toast({
|
||||
title: t("domainPickerSubdomainSanitized"),
|
||||
description: t("domainPickerSubdomainCorrected", { sub, sanitized }),
|
||||
description: t("domainPickerSubdomainCorrected", {
|
||||
sub,
|
||||
sanitized
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,7 +392,8 @@ export default function DomainPicker2({
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId || "",
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type: option.type === "provided-search" ? "provided" : "organization",
|
||||
type:
|
||||
option.type === "provided-search" ? "provided" : "organization",
|
||||
subdomain: sub || undefined,
|
||||
fullDomain,
|
||||
baseDomain: option.domain
|
||||
@@ -389,12 +417,16 @@ export default function DomainPicker2({
|
||||
});
|
||||
};
|
||||
|
||||
const isSubdomainValid = selectedBaseDomain && subdomainInput
|
||||
? validateByDomainType(subdomainInput, {
|
||||
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
|
||||
domainType: selectedBaseDomain.domainType
|
||||
})
|
||||
: true;
|
||||
const isSubdomainValid =
|
||||
selectedBaseDomain && subdomainInput
|
||||
? validateByDomainType(subdomainInput, {
|
||||
type:
|
||||
selectedBaseDomain.type === "provided-search"
|
||||
? "provided-search"
|
||||
: "organization",
|
||||
domainType: selectedBaseDomain.domainType
|
||||
})
|
||||
: true;
|
||||
|
||||
const showSubdomainInput =
|
||||
selectedBaseDomain &&
|
||||
@@ -415,7 +447,6 @@ export default function DomainPicker2({
|
||||
const hasMoreProvided =
|
||||
sortedAvailableOptions.length > providedDomainsShown;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -434,16 +465,16 @@ export default function DomainPicker2({
|
||||
showProvidedDomainSearch
|
||||
? ""
|
||||
: showSubdomainInput
|
||||
? ""
|
||||
: t("domainPickerNotAvailableForCname")
|
||||
? ""
|
||||
: t("domainPickerNotAvailableForCname")
|
||||
}
|
||||
disabled={
|
||||
!showSubdomainInput && !showProvidedDomainSearch
|
||||
}
|
||||
className={cn(
|
||||
!isSubdomainValid &&
|
||||
subdomainInput &&
|
||||
"border-red-500 focus:border-red-500"
|
||||
subdomainInput &&
|
||||
"border-red-500 focus:border-red-500"
|
||||
)}
|
||||
onChange={(e) => {
|
||||
if (showProvidedDomainSearch) {
|
||||
@@ -453,11 +484,13 @@ export default function DomainPicker2({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{t("domainPickerInvalidSubdomainStructure")}
|
||||
</p>
|
||||
)}
|
||||
{showSubdomainInput &&
|
||||
subdomainInput &&
|
||||
!isValidSubdomainStructure(subdomainInput) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{t("domainPickerInvalidSubdomainStructure")}
|
||||
</p>
|
||||
)}
|
||||
{showSubdomainInput && !subdomainInput && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
||||
@@ -483,7 +516,7 @@ export default function DomainPicker2({
|
||||
{selectedBaseDomain ? (
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{selectedBaseDomain.type ===
|
||||
"organization" ? null : (
|
||||
"organization" ? null : (
|
||||
<Zap className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
@@ -557,8 +590,12 @@ export default function DomainPicker2({
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
? t("domainPickerVerified")
|
||||
: t("domainPickerUnverified")}
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
@@ -576,21 +613,24 @@ export default function DomainPicker2({
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
{(build === "saas" ||
|
||||
build === "enterprise") && !hideFreeDomain && (
|
||||
build === "enterprise") &&
|
||||
!hideFreeDomain && (
|
||||
<CommandSeparator className="my-2" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(build === "saas" ||
|
||||
build === "enterprise") && !hideFreeDomain && (
|
||||
{(build === "saas" || build === "enterprise") &&
|
||||
!hideFreeDomain && (
|
||||
<CommandGroup
|
||||
heading={
|
||||
build === "enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomains"
|
||||
)
|
||||
: t("domainPickerFreeDomains")
|
||||
"domainPickerProvidedDomains"
|
||||
)
|
||||
: t(
|
||||
"domainPickerFreeDomains"
|
||||
)
|
||||
}
|
||||
className="py-2"
|
||||
>
|
||||
@@ -602,9 +642,13 @@ export default function DomainPicker2({
|
||||
id: "provided-search",
|
||||
domain:
|
||||
build ===
|
||||
"enterprise"
|
||||
? t("domainPickerProvidedDomain")
|
||||
: t("domainPickerFreeProvidedDomain"),
|
||||
"enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomain"
|
||||
)
|
||||
: t(
|
||||
"domainPickerFreeProvidedDomain"
|
||||
),
|
||||
type: "provided-search"
|
||||
})
|
||||
}
|
||||
@@ -615,9 +659,14 @@ export default function DomainPicker2({
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{build === "enterprise"
|
||||
? t("domainPickerProvidedDomain")
|
||||
: t("domainPickerFreeProvidedDomain")}
|
||||
{build ===
|
||||
"enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomain"
|
||||
)
|
||||
: t(
|
||||
"domainPickerFreeProvidedDomain"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
@@ -644,6 +693,15 @@ export default function DomainPicker2({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*showProvidedDomainSearch && build === "saas" && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("domainPickerNotWorkSelfHosted")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)*/}
|
||||
|
||||
{showProvidedDomainSearch && (
|
||||
<div className="space-y-4">
|
||||
{isChecking && (
|
||||
@@ -693,7 +751,7 @@ export default function DomainPicker2({
|
||||
htmlFor={option.domainNamespaceId}
|
||||
data-state={
|
||||
selectedProvidedDomain?.domainNamespaceId ===
|
||||
option.domainNamespaceId
|
||||
option.domainNamespaceId
|
||||
? "checked"
|
||||
: "unchecked"
|
||||
}
|
||||
|
||||
580
src/components/HealthCheckDialog.tsx
Normal file
580
src/components/HealthCheckDialog.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@/components/Credenza";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type HealthCheckConfig = {
|
||||
hcEnabled: boolean;
|
||||
hcPath: string;
|
||||
hcMethod: string;
|
||||
hcInterval: number;
|
||||
hcTimeout: number;
|
||||
hcStatus: number | null;
|
||||
hcHeaders?: { name: string; value: string }[] | null;
|
||||
hcScheme?: string;
|
||||
hcHostname: string;
|
||||
hcPort: number;
|
||||
hcFollowRedirects: boolean;
|
||||
hcMode: string;
|
||||
hcUnhealthyInterval: number;
|
||||
};
|
||||
|
||||
type HealthCheckDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
targetId: number;
|
||||
targetAddress: string;
|
||||
targetMethod?: string;
|
||||
initialConfig?: Partial<HealthCheckConfig>;
|
||||
onChanges: (config: HealthCheckConfig) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function HealthCheckDialog({
|
||||
open,
|
||||
setOpen,
|
||||
targetId,
|
||||
targetAddress,
|
||||
targetMethod,
|
||||
initialConfig,
|
||||
onChanges
|
||||
}: HealthCheckDialogProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const healthCheckSchema = z.object({
|
||||
hcEnabled: z.boolean(),
|
||||
hcPath: z.string().min(1, { message: t("healthCheckPathRequired") }),
|
||||
hcMethod: z
|
||||
.string()
|
||||
.min(1, { message: t("healthCheckMethodRequired") }),
|
||||
hcInterval: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.min(5, { message: t("healthCheckIntervalMin") }),
|
||||
hcTimeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, { message: t("healthCheckTimeoutMin") }),
|
||||
hcStatus: z.number().int().positive().min(100).optional().nullable(),
|
||||
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
|
||||
hcScheme: z.string().optional(),
|
||||
hcHostname: z.string(),
|
||||
hcPort: z.number().positive().gt(0).lte(65535),
|
||||
hcFollowRedirects: z.boolean(),
|
||||
hcMode: z.string(),
|
||||
hcUnhealthyInterval: z.number().int().positive().min(5)
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof healthCheckSchema>>({
|
||||
resolver: zodResolver(healthCheckSchema),
|
||||
defaultValues: {}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Determine default scheme from target method
|
||||
const getDefaultScheme = () => {
|
||||
if (initialConfig?.hcScheme) {
|
||||
return initialConfig.hcScheme;
|
||||
}
|
||||
// Default to target method if it's http or https, otherwise default to http
|
||||
if (targetMethod === "https") {
|
||||
return "https";
|
||||
}
|
||||
return "http";
|
||||
};
|
||||
|
||||
form.reset({
|
||||
hcEnabled: initialConfig?.hcEnabled,
|
||||
hcPath: initialConfig?.hcPath,
|
||||
hcMethod: initialConfig?.hcMethod,
|
||||
hcInterval: initialConfig?.hcInterval,
|
||||
hcTimeout: initialConfig?.hcTimeout,
|
||||
hcStatus: initialConfig?.hcStatus,
|
||||
hcHeaders: initialConfig?.hcHeaders,
|
||||
hcScheme: getDefaultScheme(),
|
||||
hcHostname: initialConfig?.hcHostname,
|
||||
hcPort: initialConfig?.hcPort,
|
||||
hcFollowRedirects: initialConfig?.hcFollowRedirects,
|
||||
hcMode: initialConfig?.hcMode,
|
||||
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
const watchedEnabled = form.watch("hcEnabled");
|
||||
|
||||
const handleFieldChange = async (fieldName: string, value: any) => {
|
||||
try {
|
||||
const currentValues = form.getValues();
|
||||
const updatedValues = { ...currentValues, [fieldName]: value };
|
||||
await onChanges({
|
||||
...updatedValues,
|
||||
hcStatus: updatedValues.hcStatus || null
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("healthCheckError"),
|
||||
description: t("healthCheckErrorDescription"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("configureHealthCheck")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("configureHealthCheckDescription", {
|
||||
target: targetAddress
|
||||
})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form className="space-y-6">
|
||||
{/* Enable Health Checks */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("enableHealthChecks")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"enableHealthChecksDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleFieldChange(
|
||||
"hcEnabled",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcScheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("healthScheme")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleFieldChange(
|
||||
"hcScheme",
|
||||
value
|
||||
);
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"healthSelectScheme"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
HTTP
|
||||
</SelectItem>
|
||||
<SelectItem value="https">
|
||||
HTTPS
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("healthHostname")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcHostname",
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("healthPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcPort",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("healthCheckPath")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(
|
||||
e
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcPath",
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP Method */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("httpMethod")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleFieldChange(
|
||||
"hcMethod",
|
||||
value
|
||||
);
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectHttpMethod"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">
|
||||
GET
|
||||
</SelectItem>
|
||||
<SelectItem value="POST">
|
||||
POST
|
||||
</SelectItem>
|
||||
<SelectItem value="HEAD">
|
||||
HEAD
|
||||
</SelectItem>
|
||||
<SelectItem value="PUT">
|
||||
PUT
|
||||
</SelectItem>
|
||||
<SelectItem value="DELETE">
|
||||
DELETE
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Check Interval, Timeout, and Retry Attempts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t(
|
||||
"healthyIntervalSeconds"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcInterval",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcUnhealthyInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t(
|
||||
"unhealthyIntervalSeconds"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcUnhealthyInterval",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("timeoutSeconds")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcTimeout",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormDescription>
|
||||
{t("timeIsInSeconds")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
{/* Expected Response Codes */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("expectedResponseCodes")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
value={
|
||||
field.value || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value =
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
handleFieldChange(
|
||||
"hcStatus",
|
||||
value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"expectedResponseCodesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Custom Headers */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHeaders"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-base font-semibold">
|
||||
{t("customHeaders")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleFieldChange(
|
||||
"hcHeaders",
|
||||
value
|
||||
);
|
||||
}}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customHeadersDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button onClick={() => setOpen(false)}>{t("done")}</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import Link from "next/link";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
import { useTheme } from "next-themes";
|
||||
import BrandingLogo from "./BrandingLogo";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface LayoutHeaderProps {
|
||||
showTopBar: boolean;
|
||||
@@ -14,6 +18,7 @@ interface LayoutHeaderProps {
|
||||
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const [path, setPath] = useState<string>("");
|
||||
const { env } = useEnvContext();
|
||||
|
||||
useEffect(() => {
|
||||
function getPath() {
|
||||
@@ -44,16 +49,18 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||
<div className="h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/" className="flex items-center">
|
||||
{path && (
|
||||
<Image
|
||||
src={path}
|
||||
alt="Pangolin"
|
||||
width={98}
|
||||
height={32}
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
)}
|
||||
<BrandingLogo
|
||||
width={
|
||||
env.branding.logo?.navbar?.width || 98
|
||||
}
|
||||
height={
|
||||
env.branding.logo?.navbar?.height || 32
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
{/* {build === "saas" && (
|
||||
<Badge variant="secondary">Cloud Beta</Badge>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{showTopBar && (
|
||||
|
||||
@@ -57,6 +57,16 @@ export function LayoutSidebar({
|
||||
setSidebarStateCookie(isSidebarCollapsed);
|
||||
}, [isSidebarCollapsed]);
|
||||
|
||||
function loadFooterLinks(): { text: string; href?: string }[] | undefined {
|
||||
if (env.branding.footer) {
|
||||
try {
|
||||
return JSON.parse(env.branding.footer);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse BRANDING_FOOTER", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -113,31 +123,62 @@ export function LayoutSidebar({
|
||||
<SupporterStatus isCollapsed={isSidebarCollapsed} />
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
{!isUnlocked()
|
||||
? t("communityEdition")
|
||||
: t("commercialEdition")}
|
||||
<FaGithub size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{loadFooterLinks() ? (
|
||||
<>
|
||||
{loadFooterLinks()!.map((link, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{link.href ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
{link.text}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{link.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
{!isUnlocked()
|
||||
? t("communityEdition")
|
||||
: t("commercialEdition")}
|
||||
<FaGithub size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { LoginResponse } from "@server/routers/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { LockIcon, FingerprintIcon } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import {
|
||||
@@ -49,6 +46,9 @@ import {
|
||||
} from "@app/actions/server";
|
||||
import { redirect as redirectTo } from "next/navigation";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
// @ts-ignore
|
||||
import { loadReoScript } from "reodotdev";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
@@ -60,13 +60,18 @@ type LoginFormProps = {
|
||||
redirect?: string;
|
||||
onLogin?: () => void | Promise<void>;
|
||||
idps?: LoginFormIDP[];
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
export default function LoginForm({
|
||||
redirect,
|
||||
onLogin,
|
||||
idps,
|
||||
orgId
|
||||
}: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -77,10 +82,32 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
const [showSecurityKeyPrompt, setShowSecurityKeyPrompt] = useState(false);
|
||||
|
||||
const t = useTranslations();
|
||||
const currentHost = typeof window !== "undefined" ? window.location.hostname : "";
|
||||
const currentHost =
|
||||
typeof window !== "undefined" ? window.location.hostname : "";
|
||||
const expectedHost = new URL(env.app.dashboardUrl).host;
|
||||
const isExpectedHost = currentHost === expectedHost;
|
||||
|
||||
const [reo, setReo] = useState<any | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
if (env.app.environment !== "prod") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clientID = env.server.reoClientId;
|
||||
const reoClient = await loadReoScript({ clientID });
|
||||
await reoClient.init({ clientID });
|
||||
setReo(reoClient);
|
||||
} catch (e) {
|
||||
console.error("Failed to load Reo script", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (build == "saas") {
|
||||
init();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||
@@ -183,26 +210,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAxiosError) {
|
||||
setError(
|
||||
formatAxiosError(
|
||||
e,
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to authenticate with security key"
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
console.error(e);
|
||||
setError(
|
||||
e.message ||
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"Failed to authenticate with security key"
|
||||
})
|
||||
);
|
||||
}
|
||||
console.error(e);
|
||||
setError(
|
||||
t("securityKeyAuthError", {
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowSecurityKeyPrompt(false);
|
||||
@@ -224,6 +238,18 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
code
|
||||
});
|
||||
|
||||
try {
|
||||
const identity = {
|
||||
username: email,
|
||||
type: "email" // can be one of email, github, linkedin, gmail, userID,
|
||||
};
|
||||
if (reo) {
|
||||
reo.identify(identity);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Reo identify error:", e);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
return;
|
||||
@@ -253,7 +279,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
|
||||
if (data.emailVerificationRequired) {
|
||||
if (!isExpectedHost) {
|
||||
setError(t("emailVerificationRequired", { dashboardUrl: env.app.dashboardUrl }));
|
||||
setError(
|
||||
t("emailVerificationRequired", {
|
||||
dashboardUrl: env.app.dashboardUrl
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (redirect) {
|
||||
@@ -266,7 +296,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
|
||||
if (data.twoFactorSetupRequired) {
|
||||
if (!isExpectedHost) {
|
||||
setError(t("twoFactorSetupRequired", { dashboardUrl: env.app.dashboardUrl }));
|
||||
setError(
|
||||
t("twoFactorSetupRequired", {
|
||||
dashboardUrl: env.app.dashboardUrl
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`;
|
||||
@@ -278,25 +312,13 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
await onLogin();
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.isAxiosError) {
|
||||
const errorMessage = formatAxiosError(
|
||||
e,
|
||||
t("loginError", {
|
||||
defaultValue: "Failed to log in"
|
||||
})
|
||||
);
|
||||
setError(errorMessage);
|
||||
return;
|
||||
} else {
|
||||
console.error(e);
|
||||
setError(
|
||||
e.message ||
|
||||
t("loginError", {
|
||||
defaultValue: "Failed to log in"
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(e);
|
||||
setError(
|
||||
t("loginError", {
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -307,7 +329,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
try {
|
||||
const data = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
redirect || "/"
|
||||
redirect || "/",
|
||||
orgId
|
||||
);
|
||||
const url = data.data?.redirectUrl;
|
||||
if (data.error) {
|
||||
@@ -527,7 +550,8 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||
</div>
|
||||
|
||||
{idps.map((idp) => {
|
||||
const effectiveType = idp.variant || idp.name.toLowerCase();
|
||||
const effectiveType =
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type PermissionsSelectBoxProps = {
|
||||
root?: boolean;
|
||||
@@ -17,6 +19,7 @@ type PermissionsSelectBoxProps = {
|
||||
|
||||
function getActionsCategories(root: boolean) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const actionsByCategory: Record<string, Record<string, string>> = {
|
||||
Organization: {
|
||||
@@ -34,12 +37,12 @@ function getActionsCategories(root: boolean) {
|
||||
},
|
||||
|
||||
Site: {
|
||||
[t('actionCreateSite')]: "createSite",
|
||||
[t('actionDeleteSite')]: "deleteSite",
|
||||
[t('actionGetSite')]: "getSite",
|
||||
[t('actionListSites')]: "listSites",
|
||||
[t('actionUpdateSite')]: "updateSite",
|
||||
[t('actionListSiteRoles')]: "listSiteRoles"
|
||||
[t("actionCreateSite")]: "createSite",
|
||||
[t("actionDeleteSite")]: "deleteSite",
|
||||
[t("actionGetSite")]: "getSite",
|
||||
[t("actionListSites")]: "listSites",
|
||||
[t("actionUpdateSite")]: "updateSite",
|
||||
[t("actionListSiteRoles")]: "listSiteRoles"
|
||||
},
|
||||
|
||||
Resource: {
|
||||
@@ -64,26 +67,26 @@ function getActionsCategories(root: boolean) {
|
||||
},
|
||||
|
||||
Target: {
|
||||
[t('actionCreateTarget')]: "createTarget",
|
||||
[t('actionDeleteTarget')]: "deleteTarget",
|
||||
[t('actionGetTarget')]: "getTarget",
|
||||
[t('actionListTargets')]: "listTargets",
|
||||
[t('actionUpdateTarget')]: "updateTarget"
|
||||
[t("actionCreateTarget")]: "createTarget",
|
||||
[t("actionDeleteTarget")]: "deleteTarget",
|
||||
[t("actionGetTarget")]: "getTarget",
|
||||
[t("actionListTargets")]: "listTargets",
|
||||
[t("actionUpdateTarget")]: "updateTarget"
|
||||
},
|
||||
|
||||
Role: {
|
||||
[t('actionCreateRole')]: "createRole",
|
||||
[t('actionDeleteRole')]: "deleteRole",
|
||||
[t('actionGetRole')]: "getRole",
|
||||
[t('actionListRole')]: "listRoles",
|
||||
[t('actionUpdateRole')]: "updateRole",
|
||||
[t('actionListAllowedRoleResources')]: "listRoleResources",
|
||||
[t('actionAddUserRole')]: "addUserRole"
|
||||
[t("actionCreateRole")]: "createRole",
|
||||
[t("actionDeleteRole")]: "deleteRole",
|
||||
[t("actionGetRole")]: "getRole",
|
||||
[t("actionListRole")]: "listRoles",
|
||||
[t("actionUpdateRole")]: "updateRole",
|
||||
[t("actionListAllowedRoleResources")]: "listRoleResources",
|
||||
[t("actionAddUserRole")]: "addUserRole"
|
||||
},
|
||||
"Access Token": {
|
||||
[t('actionGenerateAccessToken')]: "generateAccessToken",
|
||||
[t('actionDeleteAccessToken')]: "deleteAcessToken",
|
||||
[t('actionListAccessTokens')]: "listAccessTokens"
|
||||
[t("actionGenerateAccessToken")]: "generateAccessToken",
|
||||
[t("actionDeleteAccessToken")]: "deleteAcessToken",
|
||||
[t("actionListAccessTokens")]: "listAccessTokens"
|
||||
},
|
||||
|
||||
"Resource Rule": {
|
||||
@@ -102,36 +105,52 @@ function getActionsCategories(root: boolean) {
|
||||
}
|
||||
};
|
||||
|
||||
if (env.flags.enableClients) {
|
||||
actionsByCategory["Clients"] = {
|
||||
"Create Client": "createClient",
|
||||
"Delete Client": "deleteClient",
|
||||
"Update Client": "updateClient",
|
||||
"List Clients": "listClients",
|
||||
"Get Client": "getClient"
|
||||
};
|
||||
}
|
||||
|
||||
if (root) {
|
||||
actionsByCategory["Organization"] = {
|
||||
[t('actionListOrgs')]: "listOrgs",
|
||||
[t('actionCheckOrgId')]: "checkOrgId",
|
||||
[t('actionCreateOrg')]: "createOrg",
|
||||
[t('actionDeleteOrg')]: "deleteOrg",
|
||||
[t('actionListApiKeys')]: "listApiKeys",
|
||||
[t('actionListApiKeyActions')]: "listApiKeyActions",
|
||||
[t('actionSetApiKeyActions')]: "setApiKeyActions",
|
||||
[t('actionCreateApiKey')]: "createApiKey",
|
||||
[t('actionDeleteApiKey')]: "deleteApiKey",
|
||||
[t("actionListOrgs")]: "listOrgs",
|
||||
[t("actionCheckOrgId")]: "checkOrgId",
|
||||
[t("actionCreateOrg")]: "createOrg",
|
||||
[t("actionDeleteOrg")]: "deleteOrg",
|
||||
[t("actionListApiKeys")]: "listApiKeys",
|
||||
[t("actionListApiKeyActions")]: "listApiKeyActions",
|
||||
[t("actionSetApiKeyActions")]: "setApiKeyActions",
|
||||
[t("actionCreateApiKey")]: "createApiKey",
|
||||
[t("actionDeleteApiKey")]: "deleteApiKey",
|
||||
...actionsByCategory["Organization"]
|
||||
};
|
||||
|
||||
actionsByCategory["Identity Provider (IDP)"] = {
|
||||
[t('actionCreateIdp')]: "createIdp",
|
||||
[t('actionUpdateIdp')]: "updateIdp",
|
||||
[t('actionDeleteIdp')]: "deleteIdp",
|
||||
[t('actionListIdps')]: "listIdps",
|
||||
[t('actionGetIdp')]: "getIdp",
|
||||
[t('actionCreateIdpOrg')]: "createIdpOrg",
|
||||
[t('actionDeleteIdpOrg')]: "deleteIdpOrg",
|
||||
[t('actionListIdpOrgs')]: "listIdpOrgs",
|
||||
[t('actionUpdateIdpOrg')]: "updateIdpOrg"
|
||||
[t("actionCreateIdp")]: "createIdp",
|
||||
[t("actionUpdateIdp")]: "updateIdp",
|
||||
[t("actionDeleteIdp")]: "deleteIdp",
|
||||
[t("actionListIdps")]: "listIdps",
|
||||
[t("actionGetIdp")]: "getIdp",
|
||||
[t("actionCreateIdpOrg")]: "createIdpOrg",
|
||||
[t("actionDeleteIdpOrg")]: "deleteIdpOrg",
|
||||
[t("actionListIdpOrgs")]: "listIdpOrgs",
|
||||
[t("actionUpdateIdpOrg")]: "updateIdpOrg"
|
||||
};
|
||||
|
||||
actionsByCategory["User"] = {
|
||||
[t('actionUpdateUser')]: "updateUser",
|
||||
[t('actionGetUser')]: "getUser"
|
||||
[t("actionUpdateUser")]: "updateUser",
|
||||
[t("actionGetUser")]: "getUser"
|
||||
};
|
||||
|
||||
if (build == "saas") {
|
||||
actionsByCategory["SAAS"] = {
|
||||
["Send Usage Notification Email"]: "sendUsageNotification",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actionsByCategory;
|
||||
@@ -189,7 +208,7 @@ export default function PermissionsSelectBox({
|
||||
<CheckboxWithLabel
|
||||
variant="outlinePrimarySquare"
|
||||
id="toggle-all-permissions"
|
||||
label={t('permissionsAllowAll')}
|
||||
label={t("permissionsAllowAll")}
|
||||
checked={allPermissionsChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllPermissions(checked as boolean)
|
||||
@@ -208,7 +227,7 @@ export default function PermissionsSelectBox({
|
||||
<CheckboxWithLabel
|
||||
variant="outlinePrimarySquare"
|
||||
id={`toggle-all-${category}`}
|
||||
label={t('allowAll')}
|
||||
label={t("allowAll")}
|
||||
checked={allChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllInCategory(
|
||||
|
||||
@@ -31,27 +31,23 @@ import {
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import {
|
||||
AuthWithPasswordResponse,
|
||||
AuthWithWhitelistResponse
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
|
||||
import {
|
||||
resourcePasswordProxy,
|
||||
resourcePincodeProxy,
|
||||
resourceWhitelistProxy,
|
||||
resourceAccessProxy,
|
||||
resourceAccessProxy
|
||||
} from "@app/actions/server";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const pinSchema = z.object({
|
||||
pin: z
|
||||
@@ -90,6 +86,7 @@ type ResourceAuthPortalProps = {
|
||||
};
|
||||
redirect: string;
|
||||
idps?: LoginFormIDP[];
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
@@ -117,8 +114,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
@@ -216,7 +211,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
console.error(e);
|
||||
setWhitelistError(
|
||||
t("otpEmailErrorAuthenticate", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
@@ -249,7 +245,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
console.error(e);
|
||||
setPincodeError(
|
||||
t("pincodeErrorAuthenticate", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
@@ -282,7 +279,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
console.error(e);
|
||||
setPasswordError(
|
||||
t("passwordErrorAuthenticate", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
@@ -310,34 +308,75 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
}
|
||||
|
||||
function getTitle() {
|
||||
if (build !== "oss" && env.branding.resourceAuthPage?.titleText) {
|
||||
return env.branding.resourceAuthPage.titleText;
|
||||
}
|
||||
return t("authenticationRequired");
|
||||
}
|
||||
|
||||
function getSubtitle(resourceName: string) {
|
||||
if (build !== "oss" && env.branding.resourceAuthPage?.subtitleText) {
|
||||
return env.branding.resourceAuthPage.subtitleText
|
||||
.split("{{resourceName}}")
|
||||
.join(resourceName);
|
||||
}
|
||||
return numMethods > 1
|
||||
? t("authenticationMethodChoose", { name: props.resource.name })
|
||||
: t("authenticationRequest", { name: props.resource.name });
|
||||
? t("authenticationMethodChoose", { name: resourceName })
|
||||
: t("authenticationRequest", { name: resourceName });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!accessDenied ? (
|
||||
<div>
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Pangolin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
{build === "enterprise" ? (
|
||||
!env.branding.resourceAuthPage?.hidePoweredBy && (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://digpangolin.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{env.branding.appName || "Pangolin"}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://digpangolin.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Pangolin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{build !== "oss" &&
|
||||
env.branding?.resourceAuthPage?.showLogo && (
|
||||
<div className="flex flex-row items-center justify-center mb-3">
|
||||
<BrandingLogo
|
||||
height={
|
||||
env.branding.logo?.authPage
|
||||
?.height || 100
|
||||
}
|
||||
width={
|
||||
env.branding.logo?.authPage
|
||||
?.width || 100
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardTitle>{getTitle()}</CardTitle>
|
||||
<CardDescription>
|
||||
{getSubtitle(props.resource.name)}
|
||||
@@ -544,6 +583,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
<LoginForm
|
||||
idps={props.idps}
|
||||
redirect={props.redirect}
|
||||
orgId={props.orgId}
|
||||
onLogin={async () =>
|
||||
await handleSSOAuth()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import CertificateStatus from "@app/components/private/CertificateStatus";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
@@ -21,15 +22,13 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={3}>
|
||||
{/* 4 cols because of the certs */}
|
||||
<InfoSections cols={resource.http && build != "oss" ? 4 : 3}>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
@@ -118,6 +117,34 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
)} */}
|
||||
</>
|
||||
)}
|
||||
{/* <InfoSection> */}
|
||||
{/* <InfoSectionTitle>{t('visibility')}</InfoSectionTitle> */}
|
||||
{/* <InfoSectionContent> */}
|
||||
{/* <span> */}
|
||||
{/* {resource.enabled ? t('enabled') : t('disabled')} */}
|
||||
{/* </span> */}
|
||||
{/* </InfoSectionContent> */}
|
||||
{/* </InfoSection> */}
|
||||
{/* Certificate Status Column */}
|
||||
{resource.http && resource.domainId && resource.fullDomain && build != "oss" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("certificateStatus", {
|
||||
defaultValue: "Certificate"
|
||||
})}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CertificateStatus
|
||||
orgId={resource.orgId}
|
||||
domainId={resource.domainId}
|
||||
fullDomain={resource.fullDomain}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
|
||||
@@ -108,7 +108,8 @@ export default function SignupForm({
|
||||
emailParam
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -129,7 +130,10 @@ export default function SignupForm({
|
||||
});
|
||||
|
||||
const passwordStrength = calculatePasswordStrength(passwordValue);
|
||||
const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue;
|
||||
const doPasswordsMatch =
|
||||
passwordValue.length > 0 &&
|
||||
confirmPasswordValue.length > 0 &&
|
||||
passwordValue === confirmPasswordValue;
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { email, password } = values;
|
||||
@@ -192,7 +196,10 @@ export default function SignupForm({
|
||||
<Card className="w-full max-w-md shadow-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={58} width={175} />
|
||||
<BrandingLogo
|
||||
height={env.branding.logo?.authPage?.height || 58}
|
||||
width={env.branding.logo?.authPage?.width || 175}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
@@ -211,8 +218,8 @@ export default function SignupForm({
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
<Input
|
||||
{...field}
|
||||
disabled={!!emailParam}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -227,7 +234,8 @@ export default function SignupForm({
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
{passwordStrength.strength === "strong" && (
|
||||
{passwordStrength.strength ===
|
||||
"strong" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
@@ -238,115 +246,193 @@ export default function SignupForm({
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setPasswordValue(e.target.value);
|
||||
setPasswordValue(
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500",
|
||||
passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500",
|
||||
passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500"
|
||||
passwordStrength.strength ===
|
||||
"strong" &&
|
||||
"border-green-500 focus-visible:ring-green-500",
|
||||
passwordStrength.strength ===
|
||||
"medium" &&
|
||||
"border-yellow-500 focus-visible:ring-yellow-500",
|
||||
passwordStrength.strength ===
|
||||
"weak" &&
|
||||
passwordValue.length >
|
||||
0 &&
|
||||
"border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
|
||||
{passwordValue.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{/* Password Strength Meter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">{t("passwordStrength")}</span>
|
||||
<span className={cn(
|
||||
"text-sm font-semibold",
|
||||
passwordStrength.strength === "strong" && "text-green-600 dark:text-green-400",
|
||||
passwordStrength.strength === "medium" && "text-yellow-600 dark:text-yellow-400",
|
||||
passwordStrength.strength === "weak" && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)}
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("passwordStrength")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
passwordStrength.strength ===
|
||||
"strong" &&
|
||||
"text-green-600 dark:text-green-400",
|
||||
passwordStrength.strength ===
|
||||
"medium" &&
|
||||
"text-yellow-600 dark:text-yellow-400",
|
||||
passwordStrength.strength ===
|
||||
"weak" &&
|
||||
"text-red-600 dark:text-red-400"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={passwordStrength.percentage}
|
||||
<Progress
|
||||
value={
|
||||
passwordStrength.percentage
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Requirements Checklist */}
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-foreground mb-2">{t("passwordRequirements")}</div>
|
||||
<div className="text-sm font-medium text-foreground mb-2">
|
||||
{t("passwordRequirements")}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.length ? (
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.length ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.length ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLengthText")}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.length
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementLengthText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.uppercase ? (
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.uppercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.uppercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementUppercaseText")}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.uppercase
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementUppercaseText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.lowercase ? (
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.lowercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.lowercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLowercaseText")}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.lowercase
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementLowercaseText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.number ? (
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.number ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.number ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementNumberText")}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.number
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementNumberText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.special ? (
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.special ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.special ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementSpecialText")}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.special
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementSpecialText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Only show FormMessage when not showing our custom requirements */}
|
||||
{passwordValue.length === 0 && <FormMessage />}
|
||||
{passwordValue.length === 0 && (
|
||||
<FormMessage />
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -356,7 +442,9 @@ export default function SignupForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t('confirmPassword')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("confirmPassword")}
|
||||
</FormLabel>
|
||||
{doPasswordsMatch && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
@@ -368,23 +456,32 @@ export default function SignupForm({
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setConfirmPasswordValue(e.target.value);
|
||||
setConfirmPasswordValue(
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
doPasswordsMatch && "border-green-500 focus-visible:ring-green-500",
|
||||
confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500"
|
||||
doPasswordsMatch &&
|
||||
"border-green-500 focus-visible:ring-green-500",
|
||||
confirmPasswordValue.length >
|
||||
0 &&
|
||||
!doPasswordsMatch &&
|
||||
"border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
</p>
|
||||
)}
|
||||
{confirmPasswordValue.length > 0 &&
|
||||
!doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
</p>
|
||||
)}
|
||||
{/* Only show FormMessage when field is empty */}
|
||||
{confirmPasswordValue.length === 0 && <FormMessage />}
|
||||
{confirmPasswordValue.length === 0 && (
|
||||
<FormMessage />
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -407,28 +504,32 @@ export default function SignupForm({
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t("signUpTerms.IAgreeToThe")}
|
||||
<a
|
||||
href="https://digpangolin.com/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}
|
||||
</a>
|
||||
{t("signUpTerms.and")}
|
||||
<a
|
||||
href="https://digpangolin.com/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://digpangolin.com/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t("signUpTerms.and")}{" "}
|
||||
<a
|
||||
href="https://digpangolin.com/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
@@ -451,4 +552,4 @@ export default function SignupForm({
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useTranslations } from "next-intl";
|
||||
import { parseDataSize } from "@app/lib/dataSize";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
@@ -42,6 +43,8 @@ export type SiteRow = {
|
||||
newtUpdateAvailable?: boolean;
|
||||
online: boolean;
|
||||
address?: string;
|
||||
exitNodeName?: string;
|
||||
exitNodeEndpoint?: string;
|
||||
};
|
||||
|
||||
type SitesTableProps = {
|
||||
@@ -280,6 +283,34 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "exitNode",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("exitNode")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{originalRow.exitNodeName}</span>
|
||||
{build == "saas" && originalRow.exitNodeName &&
|
||||
['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && (
|
||||
<Badge variant="secondary">Cloud</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(env.flags.enableClients ? [{
|
||||
accessorKey: "address",
|
||||
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -26,6 +25,7 @@ type ValidateOidcTokenParams = {
|
||||
expectedState: string | undefined;
|
||||
stateCookie: string | undefined;
|
||||
idp: { name: string };
|
||||
loginPageId?: number;
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
@@ -44,7 +44,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
console.log(t('idpOidcTokenValidating'), {
|
||||
console.log(t("idpOidcTokenValidating"), {
|
||||
code: props.code,
|
||||
expectedState: props.expectedState,
|
||||
stateCookie: props.stateCookie
|
||||
@@ -59,7 +59,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
props.idpId,
|
||||
props.code || "",
|
||||
props.expectedState || "",
|
||||
props.stateCookie || ""
|
||||
props.stateCookie || "",
|
||||
props.loginPageId
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
@@ -78,7 +79,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
const redirectUrl = data.redirectUrl;
|
||||
|
||||
if (!redirectUrl) {
|
||||
router.push("/");
|
||||
router.push(env.app.dashboardUrl);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
@@ -89,8 +90,13 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
} else {
|
||||
router.push(data.redirectUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(
|
||||
t("idpErrorOidcTokenValidating", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -103,20 +109,24 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('idpConnectingTo', {name: props.idp.name})}</CardTitle>
|
||||
<CardDescription>{t('idpConnectingToDescription')}</CardDescription>
|
||||
<CardTitle>
|
||||
{t("idpConnectingTo", { name: props.idp.name })}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("idpConnectingToDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t('idpConnectingToProcess')}</span>
|
||||
<span>{t("idpConnectingToProcess")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{t('idpConnectingToFinished')}</span>
|
||||
<span>{t("idpConnectingToFinished")}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@@ -124,7 +134,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
{t('idpErrorConnectingTo', {name: props.idp.name})}
|
||||
{t("idpErrorConnectingTo", {
|
||||
name: props.idp.name
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
|
||||
538
src/components/private/AuthPageSettings.tsx
Normal file
538
src/components/private/AuthPageSettings.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { GetLoginPageResponse } from "@server/routers/private/loginPage";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import { DomainRow } from "@app/components/DomainsTable";
|
||||
import { toUnicode } from "punycode";
|
||||
import { Globe, Trash2 } from "lucide-react";
|
||||
import CertificateStatus from "@app/components/private/CertificateStatus";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { usePrivateSubscriptionStatusContext } from "@app/hooks/privateUseSubscriptionStatusContext";
|
||||
import { TierId } from "@server/lib/private/billing/tiers";
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Auth page form schema
|
||||
const AuthPageFormSchema = z.object({
|
||||
authPageDomainId: z.string().optional(),
|
||||
authPageSubdomain: z.string().optional()
|
||||
});
|
||||
|
||||
type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
|
||||
|
||||
interface AuthPageSettingsProps {
|
||||
onSaveSuccess?: () => void;
|
||||
onSaveError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export interface AuthPageSettingsRef {
|
||||
saveAuthSettings: () => Promise<void>;
|
||||
hasUnsavedChanges: () => boolean;
|
||||
}
|
||||
|
||||
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(({
|
||||
onSaveSuccess,
|
||||
onSaveError
|
||||
}, ref) => {
|
||||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const subscription = usePrivateSubscriptionStatusContext();
|
||||
const subscribed = subscription?.getTier() === TierId.STANDARD;
|
||||
|
||||
// Auth page domain state
|
||||
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
|
||||
null
|
||||
);
|
||||
const [loginPageExists, setLoginPageExists] = useState(false);
|
||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
|
||||
const [selectedDomain, setSelectedDomain] = useState<{
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
} | null>(null);
|
||||
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AuthPageFormSchema),
|
||||
defaultValues: {
|
||||
authPageDomainId: loginPage?.domainId || "",
|
||||
authPageSubdomain: loginPage?.subdomain || ""
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
|
||||
// Expose save function to parent component
|
||||
useImperativeHandle(ref, () => ({
|
||||
saveAuthSettings: async () => {
|
||||
await form.handleSubmit(onSubmit)();
|
||||
},
|
||||
hasUnsavedChanges: () => hasUnsavedChanges
|
||||
}), [form, hasUnsavedChanges]);
|
||||
|
||||
// Fetch login page and domains data
|
||||
useEffect(() => {
|
||||
if (build !== "saas") {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchLoginPage = async () => {
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<GetLoginPageResponse>>(
|
||||
`/org/${org?.org.orgId}/login-page`
|
||||
);
|
||||
if (res.status === 200) {
|
||||
setLoginPage(res.data.data);
|
||||
setLoginPageExists(true);
|
||||
// Update form with login page data
|
||||
form.setValue(
|
||||
"authPageDomainId",
|
||||
res.data.data.domainId || ""
|
||||
);
|
||||
form.setValue(
|
||||
"authPageSubdomain",
|
||||
res.data.data.subdomain || ""
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Login page doesn't exist yet, that's okay
|
||||
setLoginPage(null);
|
||||
setLoginPageExists(false);
|
||||
} finally {
|
||||
setLoadingLoginPage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDomains = async () => {
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
|
||||
`/org/${org?.org.orgId}/domains/`
|
||||
);
|
||||
if (res.status === 200) {
|
||||
const rawDomains = res.data.data.domains as DomainRow[];
|
||||
const domains = rawDomains.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain)
|
||||
}));
|
||||
setBaseDomains(domains);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch domains:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (org?.org.orgId) {
|
||||
fetchLoginPage();
|
||||
fetchDomains();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle domain selection from modal
|
||||
function handleDomainSelection(domain: {
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
}) {
|
||||
form.setValue("authPageDomainId", domain.domainId);
|
||||
form.setValue("authPageSubdomain", domain.subdomain || "");
|
||||
setEditDomainOpen(false);
|
||||
|
||||
// Update loginPage state to show the selected domain immediately
|
||||
const sanitizedSubdomain = domain.subdomain
|
||||
? finalizeSubdomainSanitize(domain.subdomain)
|
||||
: "";
|
||||
|
||||
const sanitizedFullDomain = sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||
: domain.baseDomain;
|
||||
|
||||
// Only update loginPage state if a login page already exists
|
||||
if (loginPageExists && loginPage) {
|
||||
setLoginPage({
|
||||
...loginPage,
|
||||
domainId: domain.domainId,
|
||||
subdomain: sanitizedSubdomain,
|
||||
fullDomain: sanitizedFullDomain
|
||||
});
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
// Clear auth page domain
|
||||
function clearAuthPageDomain() {
|
||||
form.setValue("authPageDomainId", "");
|
||||
form.setValue("authPageSubdomain", "");
|
||||
setLoginPage(null);
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
async function onSubmit(data: AuthPageFormValues) {
|
||||
setLoadingSave(true);
|
||||
|
||||
try {
|
||||
// Handle auth page domain
|
||||
if (data.authPageDomainId) {
|
||||
if (build !== "saas" || (build === "saas" && subscribed)) {
|
||||
const sanitizedSubdomain = data.authPageSubdomain
|
||||
? finalizeSubdomainSanitize(data.authPageSubdomain)
|
||||
: "";
|
||||
|
||||
if (loginPageExists) {
|
||||
// Login page exists on server - need to update it
|
||||
// First, we need to get the loginPageId from the server since loginPage might be null locally
|
||||
let loginPageId: number;
|
||||
|
||||
if (loginPage) {
|
||||
// We have the loginPage data locally
|
||||
loginPageId = loginPage.loginPageId;
|
||||
} else {
|
||||
// User cleared selection locally, but login page still exists on server
|
||||
// We need to fetch it to get the loginPageId
|
||||
const fetchRes = await api.get<
|
||||
AxiosResponse<GetLoginPageResponse>
|
||||
>(`/org/${org?.org.orgId}/login-page`);
|
||||
loginPageId = fetchRes.data.data.loginPageId;
|
||||
}
|
||||
|
||||
// Update existing auth page domain
|
||||
const updateRes = await api.post(
|
||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
|
||||
{
|
||||
domainId: data.authPageDomainId,
|
||||
subdomain: sanitizedSubdomain || null
|
||||
}
|
||||
);
|
||||
|
||||
if (updateRes.status === 201) {
|
||||
setLoginPage(updateRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
}
|
||||
} else {
|
||||
// No login page exists on server - create new one
|
||||
const createRes = await api.put(
|
||||
`/org/${org?.org.orgId}/login-page`,
|
||||
{
|
||||
domainId: data.authPageDomainId,
|
||||
subdomain: sanitizedSubdomain || null
|
||||
}
|
||||
);
|
||||
|
||||
if (createRes.status === 201) {
|
||||
setLoginPage(createRes.data.data);
|
||||
setLoginPageExists(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (loginPageExists) {
|
||||
// Delete existing auth page domain if no domain selected
|
||||
let loginPageId: number;
|
||||
|
||||
if (loginPage) {
|
||||
// We have the loginPage data locally
|
||||
loginPageId = loginPage.loginPageId;
|
||||
} else {
|
||||
// User cleared selection locally, but login page still exists on server
|
||||
// We need to fetch it to get the loginPageId
|
||||
const fetchRes = await api.get<
|
||||
AxiosResponse<GetLoginPageResponse>
|
||||
>(`/org/${org?.org.orgId}/login-page`);
|
||||
loginPageId = fetchRes.data.data.loginPageId;
|
||||
}
|
||||
|
||||
await api.delete(
|
||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`
|
||||
);
|
||||
setLoginPage(null);
|
||||
setLoginPageExists(false);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
router.refresh();
|
||||
onSaveSuccess?.();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("authPageErrorUpdate"),
|
||||
description: formatAxiosError(e, t("authPageErrorUpdateMessage"))
|
||||
});
|
||||
onSaveError?.(e);
|
||||
} finally {
|
||||
setLoadingSave(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("authPage")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("authPageDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{build === "saas" && !subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("orgAuthPageDisabled")}{" "}
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SettingsSectionForm>
|
||||
{loadingLoginPage ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("loading")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="auth-page-settings-form"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Label>{t("authPageDomain")}</Label>
|
||||
<div className="border p-2 rounded-md flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Globe size="14" />
|
||||
{loginPage &&
|
||||
!loginPage.domainId ? (
|
||||
<InfoPopup
|
||||
info={t(
|
||||
"domainNotFoundDescription"
|
||||
)}
|
||||
text={t("domainNotFound")}
|
||||
/>
|
||||
) : loginPage?.fullDomain ? (
|
||||
<a
|
||||
href={`${window.location.protocol}//${loginPage.fullDomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{`${window.location.protocol}//${loginPage.fullDomain}`}
|
||||
</a>
|
||||
) : form.watch(
|
||||
"authPageDomainId"
|
||||
) ? (
|
||||
// Show selected domain from form state when no loginPage exists yet
|
||||
(() => {
|
||||
const selectedDomainId =
|
||||
form.watch(
|
||||
"authPageDomainId"
|
||||
);
|
||||
const selectedSubdomain =
|
||||
form.watch(
|
||||
"authPageSubdomain"
|
||||
);
|
||||
const domain =
|
||||
baseDomains.find(
|
||||
(d) =>
|
||||
d.domainId ===
|
||||
selectedDomainId
|
||||
);
|
||||
if (domain) {
|
||||
const sanitizedSubdomain =
|
||||
selectedSubdomain
|
||||
? finalizeSubdomainSanitize(
|
||||
selectedSubdomain
|
||||
)
|
||||
: "";
|
||||
const fullDomain =
|
||||
sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||
: domain.baseDomain;
|
||||
return fullDomain;
|
||||
}
|
||||
return t("noDomainSet");
|
||||
})()
|
||||
) : (
|
||||
t("noDomainSet")
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditDomainOpen(true)
|
||||
}
|
||||
>
|
||||
{form.watch("authPageDomainId")
|
||||
? t("changeDomain")
|
||||
: t("selectDomain")}
|
||||
</Button>
|
||||
{form.watch("authPageDomainId") && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={
|
||||
clearAuthPageDomain
|
||||
}
|
||||
>
|
||||
<Trash2 size="14" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate Status */}
|
||||
{(build !== "saas" ||
|
||||
(build === "saas" && subscribed)) &&
|
||||
loginPage?.domainId &&
|
||||
loginPage?.fullDomain &&
|
||||
!hasUnsavedChanges && (
|
||||
<CertificateStatus
|
||||
orgId={org?.org.orgId || ""}
|
||||
domainId={loginPage.domainId}
|
||||
fullDomain={
|
||||
loginPage.fullDomain
|
||||
}
|
||||
autoFetch={true}
|
||||
showLabel={true}
|
||||
polling={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!form.watch("authPageDomainId") && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"addDomainToEnableCustomAuthPages"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Domain Picker Modal */}
|
||||
<Credenza
|
||||
open={editDomainOpen}
|
||||
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{loginPage
|
||||
? t("editAuthPageDomain")
|
||||
: t("setAuthPageDomain")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("selectDomainForOrgAuthPage")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<DomainPicker
|
||||
hideFreeDomain={true}
|
||||
orgId={org?.org.orgId as string}
|
||||
cols={1}
|
||||
onDomainChange={(res) => {
|
||||
const selected = {
|
||||
domainId: res.domainId,
|
||||
subdomain: res.subdomain,
|
||||
fullDomain: res.fullDomain,
|
||||
baseDomain: res.baseDomain
|
||||
};
|
||||
setSelectedDomain(selected);
|
||||
}}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedDomain) {
|
||||
handleDomainSelection(selectedDomain);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedDomain}
|
||||
>
|
||||
{t("selectDomain")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AuthPageSettings.displayName = 'AuthPageSettings';
|
||||
|
||||
export default AuthPageSettings;
|
||||
185
src/components/private/AutoProvisionConfigWidget.tsx
Normal file
185
src/components/private/AutoProvisionConfigWidget.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
type Role = {
|
||||
roleId: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type AutoProvisionConfigWidgetProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
autoProvision: boolean;
|
||||
onAutoProvisionChange: (checked: boolean) => void;
|
||||
roleMappingMode: "role" | "expression";
|
||||
onRoleMappingModeChange: (mode: "role" | "expression") => void;
|
||||
roles: Role[];
|
||||
roleIdFieldName: Path<T>;
|
||||
roleMappingFieldName: Path<T>;
|
||||
};
|
||||
|
||||
export default function AutoProvisionConfigWidget<T extends FieldValues>({
|
||||
control,
|
||||
autoProvision,
|
||||
onAutoProvisionChange,
|
||||
roleMappingMode,
|
||||
onRoleMappingModeChange,
|
||||
roles,
|
||||
roleIdFieldName,
|
||||
roleMappingFieldName
|
||||
}: AutoProvisionConfigWidgetProps<T>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label={t("idpAutoProvisionUsers")}
|
||||
defaultChecked={autoProvision}
|
||||
onCheckedChange={onAutoProvisionChange}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{autoProvision && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<FormLabel className="mb-2">
|
||||
{t("roleMapping")}
|
||||
</FormLabel>
|
||||
<FormDescription className="mb-4">
|
||||
{t("roleMappingDescription")}
|
||||
</FormDescription>
|
||||
|
||||
<RadioGroup
|
||||
value={roleMappingMode}
|
||||
onValueChange={onRoleMappingModeChange}
|
||||
className="flex space-x-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="role" id="role-mode" />
|
||||
<label
|
||||
htmlFor="role-mode"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("selectRole")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value="expression"
|
||||
id="expression-mode"
|
||||
/>
|
||||
<label
|
||||
htmlFor="expression-mode"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("roleMappingExpression")}
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{roleMappingMode === "role" ? (
|
||||
<FormField
|
||||
control={control}
|
||||
name={roleIdFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
field.onChange(Number(value))
|
||||
}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectRolePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("selectRoleDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={control}
|
||||
name={roleMappingFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
defaultValue={field.value || ""}
|
||||
value={field.value || ""}
|
||||
placeholder={t(
|
||||
"roleMappingExpressionPlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("roleMappingExpressionDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/components/private/CertificateStatus.tsx
Normal file
156
src/components/private/CertificateStatus.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { useCertificate } from "@app/hooks/privateUseCertificate";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CertificateStatusProps = {
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
fullDomain: string;
|
||||
autoFetch?: boolean;
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
onRefresh?: () => void;
|
||||
polling?: boolean;
|
||||
pollingInterval?: number;
|
||||
};
|
||||
|
||||
export default function CertificateStatus({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch = true,
|
||||
showLabel = true,
|
||||
className = "",
|
||||
onRefresh,
|
||||
polling = false,
|
||||
pollingInterval = 5000
|
||||
}: CertificateStatusProps) {
|
||||
const t = useTranslations();
|
||||
const { cert, certLoading, certError, refreshing, refreshCert } = useCertificate({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch,
|
||||
polling,
|
||||
pollingInterval
|
||||
});
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await refreshCert();
|
||||
onRefresh?.();
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "valid":
|
||||
return "text-green-500";
|
||||
case "pending":
|
||||
case "requested":
|
||||
return "text-yellow-500";
|
||||
case "expired":
|
||||
case "failed":
|
||||
return "text-red-500";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowRefreshButton = (status: string, updatedAt: string) => {
|
||||
return (
|
||||
status === "failed" ||
|
||||
status === "expired" ||
|
||||
(status === "requested" &&
|
||||
updatedAt && new Date(updatedAt).getTime() < Date.now() - 5 * 60 * 1000)
|
||||
);
|
||||
};
|
||||
|
||||
if (certLoading) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("loading")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (certError) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-red-500">
|
||||
{certError}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cert) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("none", { defaultValue: "None" })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{showLabel && (
|
||||
<span className="text-sm font-medium">
|
||||
{t("certificateStatus")}:
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-sm ${getStatusColor(cert.status)}`}>
|
||||
<span className="inline-flex items-center">
|
||||
{cert.status.charAt(0).toUpperCase() + cert.status.slice(1)}
|
||||
{shouldShowRefreshButton(cert.status, cert.updatedAt) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-2 p-0 h-auto align-middle"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title={t("restartCertificate", { defaultValue: "Restart Certificate" })}
|
||||
>
|
||||
<RotateCw
|
||||
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/private/IdpLoginButtons.tsx
Normal file
135
src/components/private/IdpLoginButtons.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { generateOidcUrlProxy, type GenerateOidcUrlResponse } from "@app/actions/server";
|
||||
import { redirect as redirectTo } from "next/navigation";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
type IdpLoginButtonsProps = {
|
||||
idps: LoginFormIDP[];
|
||||
redirect?: string;
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
export default function IdpLoginButtons({
|
||||
idps,
|
||||
redirect,
|
||||
orgId
|
||||
}: IdpLoginButtonsProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
const response = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
redirect || "/",
|
||||
orgId
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
console.log("Redirecting to:", data?.redirectUrl);
|
||||
if (data?.redirectUrl) {
|
||||
redirectToUrl = data.redirectUrl;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(
|
||||
t("loginError", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (redirectToUrl) {
|
||||
redirectTo(redirectToUrl);
|
||||
}
|
||||
}
|
||||
|
||||
if (!idps || idps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{idps.map((idp) => {
|
||||
const effectiveType = idp.variant || idp.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{effectiveType === "google" && (
|
||||
<Image
|
||||
src="/idp/google.png"
|
||||
alt="Google"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
)}
|
||||
{effectiveType === "azure" && (
|
||||
<Image
|
||||
src="/idp/azure.png"
|
||||
alt="Azure"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded"
|
||||
/>
|
||||
)}
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/private/OrgIdpDataTable.tsx
Normal file
45
src/components/private/OrgIdpDataTable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
onAdd?: () => void;
|
||||
}
|
||||
|
||||
export function IdpDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onAdd
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="idp-table"
|
||||
title={t("idp")}
|
||||
searchPlaceholder={t("idpSearch")}
|
||||
searchColumn="name"
|
||||
addButtonText={t("idpAdd")}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
219
src/components/private/OrgIdpTable.tsx
Normal file
219
src/components/private/OrgIdpTable.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
idps: IdpRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function IdpTable({ idps, orgId }: Props) {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteIdp = async (idpId: number) => {
|
||||
try {
|
||||
await api.delete(`/org/${orgId}/idp/${idpId}`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("idpDeletedDescription")
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const columns: ColumnDef<IdpRow>[] = [
|
||||
{
|
||||
accessorKey: "idpId",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
ID
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
const variant = row.original.variant;
|
||||
return (
|
||||
<IdpTypeBadge type={type} variant={variant} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedIdp(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className="ml-2"
|
||||
size="sm"
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedIdp && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelectedIdp(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("idpQuestionRemove", {
|
||||
name: selectedIdp.name
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
<b>{t("idpMessageRemove")}</b>
|
||||
</p>
|
||||
<p>{t("idpMessageConfirm")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("idpConfirmDelete")}
|
||||
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
|
||||
string={selectedIdp.name}
|
||||
title={t("idpDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IdpDataTable
|
||||
columns={columns}
|
||||
data={idps}
|
||||
onAdd={() => router.push(`/${orgId}/settings/idp/create`)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
src/components/private/RegionSelector.tsx
Normal file
101
src/components/private/RegionSelector.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Region = {
|
||||
value: string;
|
||||
label: string;
|
||||
flag: string;
|
||||
};
|
||||
|
||||
const regions: Region[] = [
|
||||
{
|
||||
value: "us",
|
||||
label: "North America",
|
||||
flag: ""
|
||||
},
|
||||
{
|
||||
value: "eu",
|
||||
label: "Europe",
|
||||
flag: ""
|
||||
}
|
||||
];
|
||||
|
||||
export default function RegionSelector() {
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>("us");
|
||||
const t = useTranslations();
|
||||
|
||||
const handleRegionChange = (value: string) => {
|
||||
setSelectedRegion(value);
|
||||
const region = regions.find((r) => r.value === value);
|
||||
if (region) {
|
||||
console.log(`Selected region: ${region.label}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<label className="flex items-center gap-1 text-sm font-medium text-muted-foreground">
|
||||
{t('regionSelectorTitle')}
|
||||
<InfoPopup info={t('regionSelectorInfo')} />
|
||||
</label>
|
||||
|
||||
<Select value={selectedRegion} onValueChange={handleRegionChange}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder={t('regionSelectorPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{regions.map((region) => (
|
||||
<SelectItem
|
||||
key={region.value}
|
||||
value={region.value}
|
||||
disabled={region.value === "eu"}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg">{region.flag}</span>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
className={
|
||||
region.value === "eu"
|
||||
? "text-muted-foreground"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{region.label}
|
||||
</span>
|
||||
{region.value === "eu" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('regionSelectorComingSoon')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
src/components/private/SplashImage.tsx
Normal file
57
src/components/private/SplashImage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
|
||||
type SplashImageProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function SplashImage({ children }: SplashImageProps) {
|
||||
const pathname = usePathname();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
function showBackgroundImage() {
|
||||
if (!env.branding.background_image_path) {
|
||||
return false;
|
||||
}
|
||||
const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource"];
|
||||
for (const prefix of pathsPrefixes) {
|
||||
if (pathname.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showBackgroundImage() && (
|
||||
<Image
|
||||
src={env.branding.background_image_path!}
|
||||
alt="Background"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
quality={100}
|
||||
className="-z-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/private/ValidateSessionTransferToken.tsx
Normal file
84
src/components/private/ValidateSessionTransferToken.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TransferSessionResponse } from "@server/routers/auth/privateTransferSession";
|
||||
|
||||
type ValidateSessionTransferTokenParams = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export default function ValidateSessionTransferToken(
|
||||
props: ValidateSessionTransferTokenParams
|
||||
) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
let doRedirect = false;
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<TransferSessionResponse>
|
||||
>(`/auth/transfer-session-token`, {
|
||||
token: props.token
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
doRedirect = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(formatAxiosError(e, "Failed to validate token"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (doRedirect) {
|
||||
redirect(env.app.dashboardUrl);
|
||||
}
|
||||
}
|
||||
|
||||
validate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,9 @@ const alertVariants = cva(
|
||||
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
success:
|
||||
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
|
||||
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500"
|
||||
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500",
|
||||
warning:
|
||||
"border-yellow-500/50 border bg-yellow-500/10 text-yellow-500 dark:border-yellow-400 [&>svg]:text-yellow-500"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from "@/components/ui/toast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({
|
||||
|
||||
24
src/contexts/privateRemoteExitNodeContext.ts
Normal file
24
src/contexts/privateRemoteExitNodeContext.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode";
|
||||
import { createContext } from "react";
|
||||
|
||||
type RemoteExitNodeContextType = {
|
||||
remoteExitNode: GetRemoteExitNodeResponse;
|
||||
updateRemoteExitNode: (updatedRemoteExitNode: Partial<GetRemoteExitNodeResponse>) => void;
|
||||
};
|
||||
|
||||
const RemoteExitNodeContext = createContext<RemoteExitNodeContextType | undefined>(undefined);
|
||||
|
||||
export default RemoteExitNodeContext;
|
||||
28
src/contexts/privateSubscriptionStatusContext.ts
Normal file
28
src/contexts/privateSubscriptionStatusContext.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { GetOrgSubscriptionResponse } from "@server/routers/private/billing";
|
||||
import { createContext } from "react";
|
||||
|
||||
type SubscriptionStatusContextType = {
|
||||
subscriptionStatus: GetOrgSubscriptionResponse | null;
|
||||
updateSubscriptionStatus: (updatedSite: GetOrgSubscriptionResponse) => void;
|
||||
isActive: () => boolean;
|
||||
getTier: () => string | null;
|
||||
};
|
||||
|
||||
const PrivateSubscriptionStatusContext = createContext<
|
||||
SubscriptionStatusContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export default PrivateSubscriptionStatusContext;
|
||||
135
src/hooks/privateUseCertificate.ts
Normal file
135
src/hooks/privateUseCertificate.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetCertificateResponse } from "@server/routers/private/certificates";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type UseCertificateProps = {
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
fullDomain: string;
|
||||
autoFetch?: boolean;
|
||||
polling?: boolean;
|
||||
pollingInterval?: number;
|
||||
};
|
||||
|
||||
type UseCertificateReturn = {
|
||||
cert: GetCertificateResponse | null;
|
||||
certLoading: boolean;
|
||||
certError: string | null;
|
||||
refreshing: boolean;
|
||||
fetchCert: () => Promise<void>;
|
||||
refreshCert: () => Promise<void>;
|
||||
clearCert: () => void;
|
||||
};
|
||||
|
||||
export function useCertificate({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch = true,
|
||||
polling = false,
|
||||
pollingInterval = 5000
|
||||
}: UseCertificateProps): UseCertificateReturn {
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [cert, setCert] = useState<GetCertificateResponse | null>(null);
|
||||
const [certLoading, setCertLoading] = useState(false);
|
||||
const [certError, setCertError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchCert = useCallback(async (showLoading = true) => {
|
||||
if (!orgId || !domainId || !fullDomain) return;
|
||||
|
||||
if (showLoading) {
|
||||
setCertLoading(true);
|
||||
}
|
||||
setCertError(null);
|
||||
try {
|
||||
const res = await api.get<AxiosResponse<GetCertificateResponse>>(
|
||||
`/org/${orgId}/certificate/${domainId}/${fullDomain}`
|
||||
);
|
||||
const certData = res.data.data;
|
||||
if (certData) {
|
||||
setCert(certData);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch certificate:", error);
|
||||
setCertError("Failed to fetch certificate");
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setCertLoading(false);
|
||||
}
|
||||
}
|
||||
}, [api, orgId, domainId, fullDomain]);
|
||||
|
||||
const refreshCert = useCallback(async () => {
|
||||
if (!cert) return;
|
||||
|
||||
setRefreshing(true);
|
||||
setCertError(null);
|
||||
try {
|
||||
await api.post(
|
||||
`/org/${orgId}/certificate/${cert.certId}/restart`,
|
||||
{}
|
||||
);
|
||||
// Update status to pending
|
||||
setTimeout(() => {
|
||||
setCert({ ...cert, status: "pending" });
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to restart certificate:", error);
|
||||
setCertError("Failed to restart certificate");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [api, orgId, cert]);
|
||||
|
||||
const clearCert = useCallback(() => {
|
||||
setCert(null);
|
||||
setCertError(null);
|
||||
}, []);
|
||||
|
||||
// Auto-fetch on mount if enabled
|
||||
useEffect(() => {
|
||||
if (autoFetch && orgId && domainId && fullDomain) {
|
||||
fetchCert();
|
||||
}
|
||||
}, [autoFetch, orgId, domainId, fullDomain, fetchCert]);
|
||||
|
||||
// Polling effect
|
||||
useEffect(() => {
|
||||
if (!polling || !orgId || !domainId || !fullDomain) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchCert(false); // Don't show loading for polling
|
||||
}, pollingInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [polling, orgId, domainId, fullDomain, pollingInterval, fetchCert]);
|
||||
|
||||
return {
|
||||
cert,
|
||||
certLoading,
|
||||
certError,
|
||||
refreshing,
|
||||
fetchCert,
|
||||
refreshCert,
|
||||
clearCert
|
||||
};
|
||||
}
|
||||
29
src/hooks/privateUseRemoteExitNodeContext.ts
Normal file
29
src/hooks/privateUseRemoteExitNodeContext.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import RemoteExitNodeContext from "@app/contexts/privateRemoteExitNodeContext";
|
||||
import { build } from "@server/build";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useRemoteExitNodeContext() {
|
||||
if (build == "oss") {
|
||||
return null;
|
||||
}
|
||||
const context = useContext(RemoteExitNodeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useRemoteExitNodeContext must be used within a RemoteExitNodeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
29
src/hooks/privateUseSubscriptionStatusContext.ts
Normal file
29
src/hooks/privateUseSubscriptionStatusContext.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import PrivateSubscriptionStatusContext from "@app/contexts/privateSubscriptionStatusContext";
|
||||
import { build } from "@server/build";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function usePrivateSubscriptionStatusContext() {
|
||||
if (build == "oss") {
|
||||
return null;
|
||||
}
|
||||
const context = useContext(PrivateSubscriptionStatusContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"usePrivateSubscriptionStatusContext must be used within an PrivateSubscriptionStatusProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
87
src/lib/privateThemeColors.ts
Normal file
87
src/lib/privateThemeColors.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
const defaultTheme = {
|
||||
light: {
|
||||
background: "oklch(0.99 0 0)",
|
||||
foreground: "oklch(0.141 0.005 285.823)",
|
||||
card: "oklch(1 0 0)",
|
||||
"card-foreground": "oklch(0.141 0.005 285.823)",
|
||||
popover: "oklch(1 0 0)",
|
||||
"popover-foreground": "oklch(0.141 0.005 285.823)",
|
||||
primary: "oklch(0.6717 0.1946 41.93)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
secondary: "oklch(0.967 0.001 286.375)",
|
||||
"secondary-foreground": "oklch(0.21 0.006 285.885)",
|
||||
muted: "oklch(0.967 0.001 286.375)",
|
||||
"muted-foreground": "oklch(0.552 0.016 285.938)",
|
||||
accent: "oklch(0.967 0.001 286.375)",
|
||||
"accent-foreground": "oklch(0.21 0.006 285.885)",
|
||||
destructive: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.92 0.004 286.32)",
|
||||
input: "oklch(0.92 0.004 286.32)",
|
||||
ring: "oklch(0.705 0.213 47.604)",
|
||||
radius: "0.65rem",
|
||||
"chart-1": "oklch(0.646 0.222 41.116)",
|
||||
"chart-2": "oklch(0.6 0.118 184.704)",
|
||||
"chart-3": "oklch(0.398 0.07 227.392)",
|
||||
"chart-4": "oklch(0.828 0.189 84.429)",
|
||||
"chart-5": "oklch(0.769 0.188 70.08)"
|
||||
},
|
||||
dark: {
|
||||
background: "oklch(0.20 0.006 285.885)",
|
||||
foreground: "oklch(0.985 0 0)",
|
||||
card: "oklch(0.21 0.006 285.885)",
|
||||
"card-foreground": "oklch(0.985 0 0)",
|
||||
popover: "oklch(0.21 0.006 285.885)",
|
||||
"popover-foreground": "oklch(0.985 0 0)",
|
||||
primary: "oklch(0.6717 0.1946 41.93)",
|
||||
"primary-foreground": "oklch(0.98 0.016 73.684)",
|
||||
secondary: "oklch(0.274 0.006 286.033)",
|
||||
"secondary-foreground": "oklch(0.985 0 0)",
|
||||
muted: "oklch(0.274 0.006 286.033)",
|
||||
"muted-foreground": "oklch(0.705 0.015 286.067)",
|
||||
accent: "oklch(0.274 0.006 286.033)",
|
||||
"accent-foreground": "oklch(0.985 0 0)",
|
||||
destructive: "oklch(0.704 0.191 22.216)",
|
||||
border: "oklch(1 0 0 / 10%)",
|
||||
input: "oklch(1 0 0 / 15%)",
|
||||
ring: "oklch(0.646 0.222 41.116)",
|
||||
"chart-1": "oklch(0.488 0.243 264.376)",
|
||||
"chart-2": "oklch(0.696 0.17 162.48)",
|
||||
"chart-3": "oklch(0.769 0.188 70.08)",
|
||||
"chart-4": "oklch(0.627 0.265 303.9)",
|
||||
"chart-5": "oklch(0.645 0.246 16.439)"
|
||||
}
|
||||
};
|
||||
|
||||
export default function setGlobalColorTheme(
|
||||
themeMode: "light" | "dark",
|
||||
colors: {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
}
|
||||
) {
|
||||
const merged = {
|
||||
light: { ...defaultTheme.light, ...colors.light },
|
||||
dark: { ...defaultTheme.dark, ...colors.dark }
|
||||
};
|
||||
|
||||
const theme = merged[themeMode] as {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
for (const key in theme) {
|
||||
document.documentElement.style.setProperty(`--${key}`, theme[key]);
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,13 @@ export function pullEnv(): Env {
|
||||
resourceAccessTokenHeadersId: process.env
|
||||
.RESOURCE_ACCESS_TOKEN_HEADERS_ID as string,
|
||||
resourceAccessTokenHeadersToken: process.env
|
||||
.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string
|
||||
.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN as string,
|
||||
reoClientId: process.env.REO_CLIENT_ID as string,
|
||||
maxmind_db_path: process.env.MAXMIND_DB_PATH as string
|
||||
},
|
||||
app: {
|
||||
environment: process.env.ENVIRONMENT as string,
|
||||
sandbox_mode: process.env.SANDBOX_MODE === "true" ? true : false,
|
||||
version: process.env.APP_VERSION as string,
|
||||
dashboardUrl: process.env.DASHBOARD_URL as string
|
||||
},
|
||||
@@ -47,5 +50,52 @@ export function pullEnv(): Env {
|
||||
hideSupporterKey:
|
||||
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false
|
||||
},
|
||||
|
||||
branding: {
|
||||
appName: process.env.BRANDING_APP_NAME as string,
|
||||
background_image_path: process.env.BACKGROUND_IMAGE_PATH as string,
|
||||
logo: {
|
||||
lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string,
|
||||
darkPath: process.env.BRANDING_LOGO_DARK_PATH as string,
|
||||
authPage: {
|
||||
width: parseInt(
|
||||
process.env.BRANDING_LOGO_AUTH_WIDTH as string
|
||||
),
|
||||
height: parseInt(
|
||||
process.env.BRANDING_LOGO_AUTH_HEIGHT as string
|
||||
)
|
||||
},
|
||||
navbar: {
|
||||
width: parseInt(
|
||||
process.env.BRANDING_LOGO_NAVBAR_WIDTH as string
|
||||
),
|
||||
height: parseInt(
|
||||
process.env.BRANDING_LOGO_NAVBAR_HEIGHT as string
|
||||
)
|
||||
}
|
||||
},
|
||||
loginPage: {
|
||||
titleText: process.env.LOGIN_PAGE_TITLE_TEXT as string,
|
||||
subtitleText: process.env.LOGIN_PAGE_SUBTITLE_TEXT as string
|
||||
},
|
||||
signupPage: {
|
||||
titleText: process.env.SIGNUP_PAGE_TITLE_TEXT as string,
|
||||
subtitleText: process.env.SIGNUP_PAGE_SUBTITLE_TEXT as string
|
||||
},
|
||||
resourceAuthPage: {
|
||||
showLogo:
|
||||
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO === "true"
|
||||
? true
|
||||
: false,
|
||||
hidePoweredBy:
|
||||
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY === "true"
|
||||
? true
|
||||
: false,
|
||||
titleText: process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT as string,
|
||||
subtitleText: process.env
|
||||
.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT as string
|
||||
},
|
||||
footer: process.env.BRANDING_FOOTER as string
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type Env = {
|
||||
app: {
|
||||
environment: string;
|
||||
sandbox_mode: boolean;
|
||||
version: string;
|
||||
dashboardUrl: string;
|
||||
};
|
||||
@@ -12,6 +13,8 @@ export type Env = {
|
||||
resourceSessionRequestParam: string;
|
||||
resourceAccessTokenHeadersId: string;
|
||||
resourceAccessTokenHeadersToken: string;
|
||||
reoClientId?: string;
|
||||
maxmind_db_path?: string;
|
||||
};
|
||||
email: {
|
||||
emailEnabled: boolean;
|
||||
@@ -25,5 +28,36 @@ export type Env = {
|
||||
disableBasicWireguardSites: boolean;
|
||||
enableClients: boolean;
|
||||
hideSupporterKey: boolean;
|
||||
}
|
||||
},
|
||||
branding: {
|
||||
appName?: string;
|
||||
background_image_path?: string;
|
||||
logo?: {
|
||||
lightPath?: string;
|
||||
darkPath?: string;
|
||||
authPage?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
navbar?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
},
|
||||
loginPage?: {
|
||||
titleText?: string;
|
||||
subtitleText?: string;
|
||||
},
|
||||
signupPage?: {
|
||||
titleText?: string;
|
||||
subtitleText?: string;
|
||||
},
|
||||
resourceAuthPage?: {
|
||||
showLogo?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
titleText?: string;
|
||||
subtitleText?: string;
|
||||
},
|
||||
footer?: string;
|
||||
};
|
||||
};
|
||||
|
||||
13
src/lib/types/privateThemeTypes.tsx
Normal file
13
src/lib/types/privateThemeTypes.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
42
src/middleware.ts
Normal file
42
src/middleware.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { build } from '@server/build';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// If build is OSS, block access to private routes
|
||||
if (build === 'oss') {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// Define private route patterns that should be blocked in OSS build
|
||||
const privateRoutes = [
|
||||
'/settings/billing',
|
||||
'/settings/remote-exit-nodes',
|
||||
'/settings/idp',
|
||||
'/auth/org'
|
||||
];
|
||||
|
||||
// Check if current path matches any private route pattern
|
||||
const isPrivateRoute = privateRoutes.some(route =>
|
||||
pathname.includes(route)
|
||||
);
|
||||
|
||||
if (isPrivateRoute) {
|
||||
// Return 404 to make it seem like the route doesn't exist
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
56
src/providers/PrivateRemoteExitNodeProvider.tsx
Normal file
56
src/providers/PrivateRemoteExitNodeProvider.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import RemoteExitNodeContext from "@app/contexts/privateRemoteExitNodeContext";
|
||||
import { GetRemoteExitNodeResponse } from "@server/routers/private/remoteExitNode";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type RemoteExitNodeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
remoteExitNode: GetRemoteExitNodeResponse;
|
||||
};
|
||||
|
||||
export function RemoteExitNodeProvider({
|
||||
children,
|
||||
remoteExitNode: serverRemoteExitNode
|
||||
}: RemoteExitNodeProviderProps) {
|
||||
const [remoteExitNode, setRemoteExitNode] = useState<GetRemoteExitNodeResponse>(serverRemoteExitNode);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const updateRemoteExitNode = (updatedRemoteExitNode: Partial<GetRemoteExitNodeResponse>) => {
|
||||
if (!remoteExitNode) {
|
||||
throw new Error(t('remoteExitNodeErrorNoUpdate'));
|
||||
}
|
||||
setRemoteExitNode((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
...updatedRemoteExitNode
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<RemoteExitNodeContext.Provider value={{ remoteExitNode, updateRemoteExitNode }}>
|
||||
{children}
|
||||
</RemoteExitNodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default RemoteExitNodeProvider;
|
||||
84
src/providers/PrivateSubscriptionStatusProvider.tsx
Normal file
84
src/providers/PrivateSubscriptionStatusProvider.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import PrivateSubscriptionStatusContext from "@app/contexts/privateSubscriptionStatusContext";
|
||||
import { getTierPriceSet } from "@server/lib/private/billing/tiers";
|
||||
import { GetOrgSubscriptionResponse } from "@server/routers/private/billing";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ProviderProps {
|
||||
children: React.ReactNode;
|
||||
subscriptionStatus: GetOrgSubscriptionResponse | null;
|
||||
env: string;
|
||||
sandbox_mode: boolean;
|
||||
}
|
||||
|
||||
export function PrivateSubscriptionStatusProvider({
|
||||
children,
|
||||
subscriptionStatus,
|
||||
env,
|
||||
sandbox_mode
|
||||
}: ProviderProps) {
|
||||
const [subscriptionStatusState, setSubscriptionStatusState] =
|
||||
useState<GetOrgSubscriptionResponse | null>(subscriptionStatus);
|
||||
|
||||
const updateSubscriptionStatus = (updatedSubscriptionStatus: GetOrgSubscriptionResponse) => {
|
||||
setSubscriptionStatusState((prev) => {
|
||||
return {
|
||||
...updatedSubscriptionStatus
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const isActive = () => {
|
||||
if (subscriptionStatus?.subscription?.status === "active") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getTier = () => {
|
||||
const tierPriceSet = getTierPriceSet(env, sandbox_mode);
|
||||
|
||||
if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) {
|
||||
// Iterate through tiers in order (earlier keys are higher tiers)
|
||||
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
|
||||
// Check if any subscription item matches this tier's price ID
|
||||
const matchingItem = subscriptionStatus.items.find(item => item.priceId === priceId);
|
||||
if (matchingItem) {
|
||||
return tierId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("No matching tier found");
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<PrivateSubscriptionStatusContext.Provider
|
||||
value={{
|
||||
subscriptionStatus: subscriptionStatusState,
|
||||
updateSubscriptionStatus,
|
||||
isActive,
|
||||
getTier
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PrivateSubscriptionStatusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrivateSubscriptionStatusProvider;
|
||||
59
src/providers/PrivateThemeDataProvider.tsx
Normal file
59
src/providers/PrivateThemeDataProvider.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import setGlobalColorTheme from "@app/lib/privateThemeColors";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type ThemeColorStateProps = {
|
||||
children: React.ReactNode;
|
||||
colors: any;
|
||||
};
|
||||
|
||||
export default function ThemeDataProvider({
|
||||
children,
|
||||
colors
|
||||
}: ThemeColorStateProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!colors) {
|
||||
setIsMounted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let lightOrDark = theme;
|
||||
|
||||
if (theme === "system" || !theme) {
|
||||
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
setGlobalColorTheme(lightOrDark as "light" | "dark", colors);
|
||||
|
||||
if (!isMounted) {
|
||||
setIsMounted(true);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user