mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-19 06:39:53 +00:00
Merge branch 'dev' into feat/resource-policies
This commit is contained in:
@@ -6,6 +6,7 @@ import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
import { build } from "@server/build";
|
||||
|
||||
type BillingSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -17,6 +18,9 @@ export default async function BillingSettingsPage({
|
||||
params
|
||||
}: BillingSettingsProps) {
|
||||
const { orgId } = await params;
|
||||
if (build !== "saas") {
|
||||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
const user = await verifySession();
|
||||
|
||||
@@ -40,6 +44,10 @@ export default async function BillingSettingsPage({
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
if (!(org?.org?.isBillingOrg && orgUser?.isOwner)) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
import { FeatureId } from "@server/lib/billing/features";
|
||||
|
||||
// Plan tier definitions matching the mockup
|
||||
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
|
||||
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
||||
|
||||
type PlanOption = {
|
||||
id: PlanId;
|
||||
@@ -73,8 +73,8 @@ type PlanOption = {
|
||||
|
||||
const planOptions: PlanOption[] = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
id: "basic",
|
||||
name: "Basic",
|
||||
price: "Free",
|
||||
tierType: null
|
||||
},
|
||||
@@ -109,38 +109,43 @@ const planOptions: PlanOption[] = [
|
||||
|
||||
// Tier limits mapping derived from limit sets
|
||||
const tierLimits: Record<
|
||||
Tier | "starter",
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number }
|
||||
Tier | "basic",
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
|
||||
> = {
|
||||
starter: {
|
||||
basic: {
|
||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier1: {
|
||||
users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier2: {
|
||||
users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
tier3: {
|
||||
users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
|
||||
domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
|
||||
remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0,
|
||||
organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0
|
||||
},
|
||||
enterprise: {
|
||||
users: 0, // Custom for enterprise
|
||||
sites: 0, // Custom for enterprise
|
||||
domains: 0, // Custom for enterprise
|
||||
remoteNodes: 0 // Custom for enterprise
|
||||
remoteNodes: 0, // Custom for enterprise
|
||||
organizations: 0 // Custom for enterprise
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,11 +184,12 @@ export default function BillingPage() {
|
||||
const SITES = "sites";
|
||||
const DOMAINS = "domains";
|
||||
const REMOTE_EXIT_NODES = "remoteExitNodes";
|
||||
const ORGINIZATIONS = "organizations";
|
||||
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingTier, setPendingTier] = useState<{
|
||||
tier: Tier | "starter";
|
||||
tier: Tier | "basic";
|
||||
action: "upgrade" | "downgrade";
|
||||
planName: string;
|
||||
price: string;
|
||||
@@ -204,7 +210,8 @@ export default function BillingPage() {
|
||||
({ subscription }) =>
|
||||
subscription?.type === "tier1" ||
|
||||
subscription?.type === "tier2" ||
|
||||
subscription?.type === "tier3"
|
||||
subscription?.type === "tier3" ||
|
||||
subscription?.type === "enterprise"
|
||||
);
|
||||
setTierSubscription(tierSub || null);
|
||||
|
||||
@@ -402,8 +409,8 @@ export default function BillingPage() {
|
||||
pendingTier.action === "upgrade" ||
|
||||
pendingTier.action === "downgrade"
|
||||
) {
|
||||
// If downgrading to starter (free tier), go to Stripe portal
|
||||
if (pendingTier.tier === "starter") {
|
||||
// If downgrading to basic (free tier), go to Stripe portal
|
||||
if (pendingTier.tier === "basic") {
|
||||
handleModifySubscription();
|
||||
} else if (hasSubscription) {
|
||||
handleChangeTier(pendingTier.tier);
|
||||
@@ -417,7 +424,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
const showTierConfirmation = (
|
||||
tier: Tier | "starter",
|
||||
tier: Tier | "basic",
|
||||
action: "upgrade" | "downgrade",
|
||||
planName: string,
|
||||
price: string
|
||||
@@ -432,13 +439,63 @@ export default function BillingPage() {
|
||||
|
||||
// Get current plan ID from tier
|
||||
const getCurrentPlanId = (): PlanId => {
|
||||
if (!hasSubscription || !currentTier) return "starter";
|
||||
if (!hasSubscription || !currentTier) return "basic";
|
||||
// Handle enterprise subscription type directly
|
||||
if (currentTier === "enterprise") return "enterprise";
|
||||
const plan = planOptions.find((p) => p.tierType === currentTier);
|
||||
return plan?.id || "starter";
|
||||
return plan?.id || "basic";
|
||||
};
|
||||
|
||||
const currentPlanId = getCurrentPlanId();
|
||||
|
||||
// Check if subscription is in a problematic state that requires attention
|
||||
const hasProblematicSubscription = (): boolean => {
|
||||
if (!tierSubscription?.subscription) return false;
|
||||
const status = tierSubscription.subscription.status;
|
||||
return (
|
||||
status === "past_due" ||
|
||||
status === "unpaid" ||
|
||||
status === "incomplete" ||
|
||||
status === "incomplete_expired"
|
||||
);
|
||||
};
|
||||
|
||||
const isProblematicState = hasProblematicSubscription();
|
||||
|
||||
// Get user-friendly subscription status message
|
||||
const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
|
||||
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
||||
|
||||
const status = tierSubscription.subscription.status;
|
||||
|
||||
switch (status) {
|
||||
case "past_due":
|
||||
return {
|
||||
title: t("billingPastDueTitle") || "Payment Past Due",
|
||||
description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
||||
};
|
||||
case "unpaid":
|
||||
return {
|
||||
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
||||
description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
||||
};
|
||||
case "incomplete":
|
||||
return {
|
||||
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
||||
description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
|
||||
};
|
||||
case "incomplete_expired":
|
||||
return {
|
||||
title: t("billingIncompleteExpiredTitle") || "Payment Expired",
|
||||
description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusMessage = getSubscriptionStatusMessage();
|
||||
|
||||
// Get button label and action for each plan
|
||||
const getPlanAction = (plan: PlanOption) => {
|
||||
if (plan.id === "enterprise") {
|
||||
@@ -451,8 +508,8 @@ export default function BillingPage() {
|
||||
}
|
||||
|
||||
if (plan.id === currentPlanId) {
|
||||
// If it's the starter plan (starter with no subscription), show as current but disabled
|
||||
if (plan.id === "starter" && !hasSubscription) {
|
||||
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||
if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
|
||||
return {
|
||||
label: "Current Plan",
|
||||
action: () => {},
|
||||
@@ -460,8 +517,17 @@ export default function BillingPage() {
|
||||
disabled: true
|
||||
};
|
||||
}
|
||||
// If on free tier but has a problematic subscription, allow them to manage it
|
||||
if (plan.id === "basic" && isProblematicState) {
|
||||
return {
|
||||
label: "Manage Subscription",
|
||||
action: handleModifySubscription,
|
||||
variant: "default" as const,
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Modify Current Plan",
|
||||
label: "Manage Current Plan",
|
||||
action: handleModifySubscription,
|
||||
variant: "default" as const,
|
||||
disabled: false
|
||||
@@ -484,10 +550,10 @@ export default function BillingPage() {
|
||||
plan.name,
|
||||
plan.price + (" " + plan.priceDetail || "")
|
||||
);
|
||||
} else if (plan.id === "starter") {
|
||||
// Show confirmation for downgrading to starter (free tier)
|
||||
} else if (plan.id === "basic") {
|
||||
// Show confirmation for downgrading to basic (free tier)
|
||||
showTierConfirmation(
|
||||
"starter",
|
||||
"basic",
|
||||
"downgrade",
|
||||
plan.name,
|
||||
plan.price
|
||||
@@ -497,7 +563,7 @@ export default function BillingPage() {
|
||||
}
|
||||
},
|
||||
variant: "outline" as const,
|
||||
disabled: false
|
||||
disabled: isProblematicState
|
||||
};
|
||||
}
|
||||
|
||||
@@ -516,7 +582,7 @@ export default function BillingPage() {
|
||||
}
|
||||
},
|
||||
variant: "outline" as const,
|
||||
disabled: false
|
||||
disabled: isProblematicState
|
||||
};
|
||||
};
|
||||
|
||||
@@ -566,7 +632,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
// Check if downgrading to a tier would violate current usage limits
|
||||
const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
|
||||
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
|
||||
feature: string;
|
||||
currentUsage: number;
|
||||
newLimit: number;
|
||||
@@ -619,6 +685,16 @@ export default function BillingPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check organizations
|
||||
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
||||
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
|
||||
violations.push({
|
||||
feature: "Organizations",
|
||||
currentUsage: organizationsUsage,
|
||||
newLimit: limits.organizations
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
};
|
||||
|
||||
@@ -632,6 +708,26 @@ export default function BillingPage() {
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{/* Subscription Status Alert */}
|
||||
{isProblematicState && statusMessage && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{statusMessage.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{statusMessage.description}
|
||||
{" "}
|
||||
<button
|
||||
onClick={handleModifySubscription}
|
||||
className="underline font-semibold hover:no-underline"
|
||||
>
|
||||
{t("billingManageSubscription") || "Manage your subscription"}
|
||||
</button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Your Plan Section */}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -676,22 +772,50 @@ export default function BillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -752,7 +876,7 @@ export default function BillingPage() {
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{t("billingMaximumLimits") || "Maximum Limits"}
|
||||
</div>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={5}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingUsers") || "Users"}
|
||||
@@ -855,6 +979,41 @@ export default function BillingPage() {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{isOverLimit(ORGINIZATIONS) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle className="flex items-center gap-1 text-xs">
|
||||
{t("billingRemoteNodes") ||
|
||||
@@ -872,7 +1031,7 @@ export default function BillingPage() {
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
null && "nodes"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -885,7 +1044,7 @@ export default function BillingPage() {
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
null && "nodes"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1016,6 +1175,17 @@ export default function BillingPage() {
|
||||
"Domains"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.organizations
|
||||
}{" "}
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { build } from "@server/build";
|
||||
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
|
||||
type LicensesSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -27,6 +29,26 @@ export default async function LicensesSetingsLayoutProps({
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
let orgUser = null;
|
||||
try {
|
||||
const res = await getCachedOrgUser(orgId, user.userId);
|
||||
orgUser = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
let org = null;
|
||||
try {
|
||||
const res = await getCachedOrg(orgId);
|
||||
org = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
if (!org?.org?.isBillingOrg || !orgUser?.isOwner) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
/>
|
||||
|
||||
<ClientProvider client={client}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<ClientInfoCard />
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default async function GeneralSettingsPage({
|
||||
description={t("orgSettingsDescription")}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<OrgInfoCard />
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
|
||||
@@ -3,11 +3,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
useState,
|
||||
useTransition,
|
||||
useActionState
|
||||
} from "react";
|
||||
import { useState, useTransition, useActionState } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -54,7 +50,7 @@ export default function GeneralPage() {
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<GeneralSectionForm org={org.org} />
|
||||
{build !== "saas" && <DeleteForm org={org.org} />}
|
||||
{!org.org.isBillingOrg && <DeleteForm org={org.org} />}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,12 +72,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const primaryOrg = orgs.find((o) => o.orgId === params.orgId)?.isPrimaryOrg;
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgId={params.orgId}
|
||||
orgs={orgs}
|
||||
navItems={orgNavSections(env)}
|
||||
navItems={orgNavSections(env, {
|
||||
isPrimaryOrg: primaryOrg
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</Layout>
|
||||
|
||||
@@ -74,7 +74,9 @@ export default async function ClientResourcesPage(
|
||||
niceId: siteResource.niceId,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
disableIcmp: siteResource.disableIcmp || false
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -187,7 +187,11 @@ export default function ResourceAuthenticationPage() {
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso);
|
||||
const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setSsoEnabled(resource.sso ?? false);
|
||||
}, [resource.sso]);
|
||||
|
||||
const [selectedIdpId, setSelectedIdpId] = useState<number | null>(
|
||||
resource.skipToIdpId || null
|
||||
@@ -472,7 +476,7 @@ export default function ResourceAuthenticationPage() {
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={resource.sso}
|
||||
checked={ssoEnabled}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
|
||||
@@ -800,8 +804,13 @@ function OneTimePasswordFormSection({
|
||||
}: OneTimePasswordFormSectionProps) {
|
||||
const { env } = useEnvContext();
|
||||
const [whitelistEnabled, setWhitelistEnabled] = useState(
|
||||
resource.emailWhitelistEnabled
|
||||
resource.emailWhitelistEnabled ?? false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setWhitelistEnabled(resource.emailWhitelistEnabled);
|
||||
}, [resource.emailWhitelistEnabled]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [loadingSaveWhitelist, startTransition] = useTransition();
|
||||
@@ -894,7 +903,7 @@ function OneTimePasswordFormSection({
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label={t("otpEmailWhitelist")}
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
checked={whitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
disabled={!env.email.emailEnabled}
|
||||
/>
|
||||
|
||||
@@ -113,7 +113,12 @@ export default function ResourceRules(props: {
|
||||
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setRulesEnabled(resource.applyRules);
|
||||
}, [resource.applyRules]);
|
||||
|
||||
const [openCountrySelect, setOpenCountrySelect] = useState(false);
|
||||
const [countrySelectValue, setCountrySelectValue] = useState("");
|
||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||
@@ -836,7 +841,7 @@ export default function ResourceRules(props: {
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label={t("rulesEnable")}
|
||||
defaultChecked={rulesEnabled}
|
||||
checked={rulesEnabled}
|
||||
onCheckedChange={(val) => setRulesEnabled(val)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState } from "react";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
@@ -187,21 +187,22 @@ export default function GeneralPage() {
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"enableDockerSocketDescription"
|
||||
)}{" "}
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
"enableDockerSocketLink"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{t.rich(
|
||||
"enableDockerSocketDescription",
|
||||
{
|
||||
docsLink: (chunks) => (
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -56,7 +56,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
/>
|
||||
|
||||
<SiteProvider site={site}>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<SiteInfoCard />
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,6 @@ import { QRCodeCanvas } from "qrcode.react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
||||
import { id } from "date-fns/locale";
|
||||
|
||||
type SiteType = "newt" | "wireguard" | "local";
|
||||
|
||||
|
||||
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
74
src/app/auth/delete-account/DeleteAccountClient.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
|
||||
import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
|
||||
type DeleteAccountClientProps = {
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export default function DeleteAccountClient({
|
||||
displayName
|
||||
}: DeleteAccountClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
function handleUseDifferentAccount() {
|
||||
api.post("/auth/logout")
|
||||
.catch((e) => {
|
||||
console.error(t("logoutError"), e);
|
||||
toast({
|
||||
title: t("logoutError"),
|
||||
description: formatAxiosError(e, t("logoutError"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.push(
|
||||
"/auth/login?internal_redirect=/auth/delete-account"
|
||||
);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserProfileCard
|
||||
identifier={displayName}
|
||||
description={t("signingAs")}
|
||||
onUseDifferentAccount={handleUseDifferentAccount}
|
||||
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountDescription")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
</div>
|
||||
<DeleteAccountConfirmDialog
|
||||
open={isDialogOpen}
|
||||
setOpen={setIsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/auth/delete-account/page.tsx
Normal file
28
src/app/auth/delete-account/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { build } from "@server/build";
|
||||
import { cache } from "react";
|
||||
import DeleteAccountClient from "./DeleteAccountClient";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DeleteAccountPage() {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
const displayName = getUserDisplayName({ user });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">{t("deleteAccount")}</h1>
|
||||
<DeleteAccountClient displayName={displayName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { redirect } from "next/navigation";
|
||||
import DeviceLoginForm from "@/components/DeviceLoginForm";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { cache } from "react";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ code?: string; user?: string }>;
|
||||
searchParams: Promise<{ code?: string; user?: string; authPath?: string }>;
|
||||
};
|
||||
|
||||
function deviceRedirectSearchParams(params: {
|
||||
@@ -30,11 +31,11 @@ export default async function DeviceLoginPage({ searchParams }: Props) {
|
||||
|
||||
if (!user) {
|
||||
const redirectDestination = `/auth/login/device${deviceRedirectSearchParams({ code, user: params.user })}`;
|
||||
const loginUrl = new URL("/auth/login", "http://x");
|
||||
const authPath = cleanRedirect(params.authPath || "/auth/login");
|
||||
const loginUrl = new URL(authPath, "http://x");
|
||||
loginUrl.searchParams.set("forceLogin", "true");
|
||||
loginUrl.searchParams.set("redirect", redirectDestination);
|
||||
if (defaultUser) loginUrl.searchParams.set("user", defaultUser);
|
||||
console.log("loginUrl", loginUrl.pathname + loginUrl.search);
|
||||
redirect(loginUrl.pathname + loginUrl.search);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export default async function Page(props: {
|
||||
redirect: string | undefined;
|
||||
email: string | undefined;
|
||||
fromSmartLogin: string | undefined;
|
||||
skipVerificationEmail: string | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
@@ -75,6 +76,10 @@ export default async function Page(props: {
|
||||
inviteId={inviteId}
|
||||
emailParam={searchParams.email}
|
||||
fromSmartLogin={searchParams.fromSmartLogin === "true"}
|
||||
skipVerificationEmail={
|
||||
searchParams.skipVerificationEmail === "true" ||
|
||||
searchParams.skipVerificationEmail === "1"
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Geist, Inter, Manrope, Open_Sans } from "next/font/google";
|
||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
@@ -24,6 +23,7 @@ import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -32,10 +32,12 @@ export const metadata: Metadata = {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const font = Inter({
|
||||
const inter = Inter({
|
||||
subsets: ["latin"]
|
||||
});
|
||||
|
||||
const fontClassName = inter.className;
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
@@ -79,16 +81,16 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen-safe overflow-hidden`}>
|
||||
<body className={`${fontClassName} h-screen-safe overflow-hidden`}>
|
||||
<StoreInternalRedirect />
|
||||
<TopLoader />
|
||||
{build === "saas" && (
|
||||
{/* build === "saas" && (
|
||||
<Script
|
||||
src="https://rybbit.fossorial.io/api/script.js"
|
||||
data-site-id="fe1ff2a33287"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
)*/}
|
||||
<ViewportHeightFix />
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider
|
||||
@@ -125,9 +127,9 @@ export default async function RootLayout({
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
{/*process.env.NODE_ENV === "development" && (
|
||||
<TailwindIndicator />
|
||||
)}
|
||||
)*/}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
Building2,
|
||||
ChartLine,
|
||||
Combine,
|
||||
CreditCard,
|
||||
@@ -12,10 +13,11 @@ import {
|
||||
KeyRound,
|
||||
Laptop,
|
||||
Link as LinkIcon,
|
||||
Logs, // Added from 'dev' branch
|
||||
Logs,
|
||||
MonitorUp,
|
||||
Plug,
|
||||
ReceiptText,
|
||||
ScanEye, // Added from 'dev' branch
|
||||
ScanEye,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldIcon,
|
||||
@@ -33,6 +35,10 @@ export type SidebarNavSection = {
|
||||
items: SidebarNavItem[];
|
||||
};
|
||||
|
||||
export type OrgNavSectionsOptions = {
|
||||
isPrimaryOrg?: boolean;
|
||||
};
|
||||
|
||||
// Merged from 'user-management-and-resources' branch
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
{
|
||||
@@ -42,14 +48,17 @@ export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
}
|
||||
];
|
||||
|
||||
export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
export const orgNavSections = (
|
||||
env?: Env,
|
||||
options?: OrgNavSectionsOptions
|
||||
): SidebarNavSection[] => [
|
||||
{
|
||||
heading: "sidebarGeneral",
|
||||
heading: "network",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarSites",
|
||||
href: "/{orgId}/settings/sites",
|
||||
icon: <Combine className="size-4 flex-none" />
|
||||
icon: <Plug className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarResources",
|
||||
@@ -100,17 +109,22 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "access",
|
||||
heading: "accessControl",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarUsers",
|
||||
icon: <User className="size-4 flex-none" />,
|
||||
title: "sidebarTeam",
|
||||
icon: <Users className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarUsers",
|
||||
href: "/{orgId}/settings/access/users",
|
||||
icon: <User className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarRoles",
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarInvitations",
|
||||
href: "/{orgId}/settings/access/invitations",
|
||||
@@ -118,11 +132,6 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "sidebarRoles",
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
...(build !== "oss"
|
||||
? [
|
||||
{
|
||||
@@ -170,90 +179,86 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "sidebarLogsAndAnalytics",
|
||||
items: (() => {
|
||||
const logItems: SidebarNavItem[] = [
|
||||
{
|
||||
title: "sidebarLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
const analytics = {
|
||||
title: "sidebarLogsAnalytics",
|
||||
href: "/{orgId}/settings/logs/analytics",
|
||||
icon: <ChartLine className="h-4 w-4" />
|
||||
};
|
||||
|
||||
// If only one log item, return it directly without grouping
|
||||
if (logItems.length === 1) {
|
||||
return [analytics, ...logItems];
|
||||
}
|
||||
|
||||
// If multiple log items, create a group
|
||||
return [
|
||||
analytics,
|
||||
{
|
||||
title: "sidebarLogs",
|
||||
icon: <Logs className="size-4 flex-none" />,
|
||||
items: logItems
|
||||
}
|
||||
];
|
||||
})()
|
||||
},
|
||||
{
|
||||
heading: "sidebarOrganization",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
title: "sidebarLogsAndAnalytics",
|
||||
icon: <ChartLine className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarLogsAnalytics",
|
||||
href: "/{orgId}/settings/logs/analytics",
|
||||
icon: <ChartLine className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: (
|
||||
<SquareMousePointer className="size-4 flex-none" />
|
||||
)
|
||||
},
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
title: "sidebarManagement",
|
||||
icon: <Building2 className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarApiKeys",
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarBluePrints",
|
||||
href: "/{orgId}/settings/blueprints",
|
||||
icon: <ReceiptText className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
},
|
||||
...(build == "saas" && options?.isPrimaryOrg
|
||||
? [
|
||||
{
|
||||
title: "sidebarBillingAndLicenses",
|
||||
icon: <CreditCard className="size-4 flex-none" />,
|
||||
items: [
|
||||
{
|
||||
title: "sidebarBilling",
|
||||
href: "/{orgId}/settings/billing",
|
||||
icon: (
|
||||
<CreditCard className="size-4 flex-none" />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "sidebarEnterpriseLicenses",
|
||||
href: "/{orgId}/settings/license",
|
||||
icon: (
|
||||
<TicketCheck className="size-4 flex-none" />
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "sidebarSettings",
|
||||
href: "/{orgId}/settings/general",
|
||||
icon: <Settings className="size-4 flex-none" />
|
||||
},
|
||||
|
||||
...(build == "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarBilling",
|
||||
href: "/{orgId}/settings/billing",
|
||||
icon: <CreditCard className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(build == "saas"
|
||||
? [
|
||||
{
|
||||
title: "sidebarEnterpriseLicenses",
|
||||
href: "/{orgId}/settings/license",
|
||||
icon: <TicketCheck className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -73,7 +73,7 @@ export default async function Page(props: {
|
||||
|
||||
if (!orgs.length) {
|
||||
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
|
||||
redirect("/setup");
|
||||
redirect("/setup?firstOrg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,14 @@ export default async function Page(props: {
|
||||
targetOrgId = lastOrgCookie;
|
||||
} else {
|
||||
let ownedOrg = orgs.find((org) => org.isOwner);
|
||||
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
|
||||
if (!ownedOrg) {
|
||||
if (primaryOrg) {
|
||||
ownedOrg = primaryOrg;
|
||||
} else {
|
||||
ownedOrg = orgs[0];
|
||||
}
|
||||
}
|
||||
if (!ownedOrg) {
|
||||
ownedOrg = orgs[0];
|
||||
}
|
||||
|
||||
@@ -4,19 +4,14 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { build } from "@server/build";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
@@ -35,7 +30,7 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { ArrowRight, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type Step = "org" | "site" | "resources";
|
||||
@@ -45,6 +40,7 @@ export default function StepperForm() {
|
||||
const [orgIdTaken, setOrgIdTaken] = useState(false);
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
@@ -54,7 +50,10 @@ export default function StepperForm() {
|
||||
|
||||
const orgSchema = z.object({
|
||||
orgName: z.string().min(1, { message: t("orgNameRequired") }),
|
||||
orgId: z.string().min(1, { message: t("orgIdRequired") }),
|
||||
orgId: z
|
||||
.string()
|
||||
.min(1, { message: t("orgIdRequired") })
|
||||
.max(32, { message: t("orgIdMaxLength") }),
|
||||
subnet: z.string().min(1, { message: t("subnetRequired") }),
|
||||
utilitySubnet: z.string().min(1, { message: t("subnetRequired") })
|
||||
});
|
||||
@@ -71,12 +70,27 @@ export default function StepperForm() {
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isFirstOrg = searchParams.get("firstOrg") != null;
|
||||
|
||||
// Fetch default subnet on component mount
|
||||
useEffect(() => {
|
||||
fetchDefaultSubnet();
|
||||
}, []);
|
||||
|
||||
// Prefill org name and id when build is saas and firstOrg query param is set
|
||||
useEffect(() => {
|
||||
if (build !== "saas" || !user || !isFirstOrg) return;
|
||||
|
||||
const orgName = user.email
|
||||
? `${user.email}'s Organization`
|
||||
: "My Organization";
|
||||
const orgId = `org_${user.userId}`;
|
||||
orgForm.setValue("orgName", orgName);
|
||||
orgForm.setValue("orgId", orgId);
|
||||
debouncedCheckOrgIdAvailability(orgId);
|
||||
}, []);
|
||||
|
||||
const fetchDefaultSubnet = async () => {
|
||||
try {
|
||||
const res = await api.get(`/pick-org-defaults`);
|
||||
@@ -129,6 +143,15 @@ export default function StepperForm() {
|
||||
.replace(/^-+|-+$/g, "");
|
||||
};
|
||||
|
||||
const sanitizeOrgId = (value: string) => {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 32);
|
||||
};
|
||||
|
||||
async function orgSubmit(values: z.infer<typeof orgSchema>) {
|
||||
if (orgIdTaken) {
|
||||
return;
|
||||
@@ -161,263 +184,254 @@ export default function StepperForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("setupNewOrg")}</CardTitle>
|
||||
<CardDescription>{t("setupCreate")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<section className="space-y-6">
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "org"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "org"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "site"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "site"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("siteCreate")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "resources"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "resources"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateResources")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("setupNewOrg")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{t("setupCreate")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "org"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "org"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "site"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "site"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("siteCreate")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === "resources"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
currentStep === "resources"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("setupCreateResources")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
|
||||
{currentStep === "org" && (
|
||||
<Form {...orgForm}>
|
||||
<form
|
||||
onSubmit={orgForm.handleSubmit(orgSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("setupOrgName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
// Prevent "/" in orgName input
|
||||
const sanitizedValue =
|
||||
e.target.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
);
|
||||
const orgId =
|
||||
generateId(
|
||||
sanitizedValue
|
||||
);
|
||||
orgForm.setValue(
|
||||
"orgId",
|
||||
orgId
|
||||
);
|
||||
orgForm.setValue(
|
||||
"orgName",
|
||||
sanitizedValue
|
||||
);
|
||||
debouncedCheckOrgIdAvailability(
|
||||
orgId
|
||||
);
|
||||
}}
|
||||
value={field.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("orgDisplayName")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("orgId")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupIdentifierMessage"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{currentStep === "org" && (
|
||||
<Form {...orgForm}>
|
||||
<form
|
||||
onSubmit={orgForm.handleSubmit(orgSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("setupOrgName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
// Prevent "/" in orgName input
|
||||
const sanitizedValue =
|
||||
e.target.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
);
|
||||
const orgId =
|
||||
generateId(sanitizedValue);
|
||||
orgForm.setValue(
|
||||
"orgId",
|
||||
orgId
|
||||
);
|
||||
orgForm.setValue(
|
||||
"orgName",
|
||||
sanitizedValue
|
||||
);
|
||||
debouncedCheckOrgIdAvailability(
|
||||
orgId
|
||||
);
|
||||
}}
|
||||
value={field.value.replace(
|
||||
/\//g,
|
||||
"-"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("orgDisplayName")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="orgId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("orgId")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = sanitizeOrgId(
|
||||
e.target.value
|
||||
);
|
||||
field.onChange(value);
|
||||
setOrgIdTaken(false);
|
||||
if (value) {
|
||||
debouncedCheckOrgIdAvailability(
|
||||
value
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("setupIdentifierMessage")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapsible
|
||||
open={isAdvancedOpen}
|
||||
onOpenChange={setIsAdvancedOpen}
|
||||
className="space-y-2"
|
||||
<Collapsible
|
||||
open={isAdvancedOpen}
|
||||
onOpenChange={setIsAdvancedOpen}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm">
|
||||
{t("advancedSettings")}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("toggle")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<h4 className="text-sm">
|
||||
{t("advancedSettings")}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t("toggle")}
|
||||
</span>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="subnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"setupSubnetAdvanced"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupSubnetDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="subnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("setupSubnetAdvanced")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("setupSubnetDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="utilitySubnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("setupUtilitySubnet")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupUtilitySubnetDescription"
|
||||
)}
|
||||
/>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<FormField
|
||||
control={orgForm.control}
|
||||
name="utilitySubnet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"setupUtilitySubnet"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"setupUtilitySubnetDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{orgIdTaken && !orgCreated ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("setupErrorIdentifier")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{orgIdTaken && !orgCreated ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("setupErrorIdentifier")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{/* Error Alert removed, errors now shown as toast */}
|
||||
|
||||
{/* Error Alert removed, errors now shown as toast */}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || orgIdTaken}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || orgIdTaken}
|
||||
>
|
||||
{t("setupCreateOrg")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user