Compare commits

..

1 Commits

Author SHA1 Message Date
Narciso ffbb516c31 fix(sso): mark self-hosted owner as verified when SSO is configured 2026-09-17 19:50:17 -04:00
35 changed files with 445 additions and 20017 deletions
-8403
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,6 +1,3 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
PORT=3000
NODE_ENV=development
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
HOSTINGER_API_KEY=
@@ -6,41 +6,28 @@ import { api } from "@/utils/api";
export const TrialBanner = () => {
const router = useRouter();
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
const { mutateAsync: createCustomerPortalSession, isPending } =
api.stripe.createCustomerPortalSession.useMutation();
const handleAddPaymentMethod = async () => {
const session = await createCustomerPortalSession().catch(() => null);
if (session?.url) {
window.open(session.url);
return;
}
router.push("/dashboard/settings/billing");
};
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
if (!billingStatus?.isOnTrial) {
return null;
}
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
return (
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
<span className="flex items-center gap-2">
<Rocket className="h-4 w-4 text-primary shrink-0" />
{daysRemaining > 0
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
: "Your free trial ends today. Add a payment method to keep your servers."}
? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
: "Your free trial ends today."}
</span>
<Button
size="sm"
variant="default"
className="h-7"
isLoading={isPending}
onClick={handleAddPaymentMethod}
onClick={() => router.push("/dashboard/settings/billing")}
>
Add payment method
Upgrade now
</Button>
</div>
);
@@ -278,9 +278,8 @@ export const OnboardingWizard = ({ onClose }: Props) => {
<DialogTitle>Skip onboarding?</DialogTitle>
<DialogDescription>
If this is your first time using Dokploy, we recommend going
through these steps – it only takes a couple of minutes and gives
you a feel for how projects, servers, and deployments fit
together.
through these steps — it only takes a couple of minutes and gives
you a feel for how projects, servers and deployments fit together.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -15,50 +15,13 @@ const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
type Tier = "hobby" | "startup";
const PLANS: {
tier: Tier;
name: string;
description: string;
price: number;
features: string[];
recommended?: boolean;
}[] = [
{
tier: "hobby",
name: "Hobby",
description: "For individual developers",
price: calculatePriceHobby(1, false),
features: [
"Setup 1 server",
"Unlimited apps & databases",
"2 environments",
"Community support",
],
},
{
tier: "startup",
name: "Startup",
description: "For small to mid-size teams",
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
recommended: true,
features: [
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
],
},
];
interface Props {
onNext: () => void;
}
export const PlanStep = ({ onNext }: Props) => {
const [loading, setLoading] = useState<
`${"trial" | "checkout"}:${Tier}` | null
const [loadingTier, setLoadingTier] = useState<
"hobby" | "startup" | "trial" | null
>(null);
const { data } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
@@ -67,12 +30,12 @@ export const PlanStep = ({ onNext }: Props) => {
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const handleCheckout = async (tier: Tier) => {
const handleCheckout = async (tier: "hobby" | "startup") => {
if (!data) return;
const productId =
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
if (!productId) return;
setLoading(`checkout:${tier}`);
setLoadingTier(tier);
try {
const stripe = await stripePromise;
const session = await createCheckoutSession({
@@ -84,14 +47,14 @@ export const PlanStep = ({ onNext }: Props) => {
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
} catch {
toast.error("Error starting checkout");
setLoading(null);
setLoadingTier(null);
}
};
const handleTrial = async (tier: Tier) => {
setLoading(`trial:${tier}`);
const handleTrial = async () => {
setLoadingTier("trial");
try {
await startFreeTrial({ tier });
await startFreeTrial();
await utils.project.onboardingStatus.invalidate();
toast.success("Your 7-day trial has started");
onNext();
@@ -100,7 +63,7 @@ export const PlanStep = ({ onNext }: Props) => {
error instanceof Error ? error.message : "Error starting trial",
);
} finally {
setLoading(null);
setLoadingTier(null);
}
};
@@ -116,72 +79,134 @@ export const PlanStep = ({ onNext }: Props) => {
Start free, upgrade when ready.
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Try any plan free for 7 days. No credit card required — add one only
if you decide to stay.
No credit card for the trial — add one only if you decide to stay.
</p>
</div>
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
<div>
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
Recommended
</span>
<p className={`${displayFont.className} text-2xl mt-3`}>
7-day free trial
</p>
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
No card required — cancel anytime.
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"Setup 1 server",
"Unlimited apps & databases",
"Community support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
>
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
size="lg"
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
isLoading={loadingTier === "trial"}
disabled={loadingTier !== null}
onClick={handleTrial}
>
Start trial
<ArrowRightIcon className="size-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
{PLANS.map((plan) => {
const productId =
plan.tier === "hobby"
? data?.hobbyProductId
: data?.startupProductId;
return (
<div
key={plan.tier}
className="flex flex-col justify-between gap-6 bg-background p-7"
>
<div>
{plan.recommended && (
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
Recommended
</span>
)}
<p className="font-medium">{plan.name}</p>
<p className="text-sm text-muted-foreground mt-1">
{plan.description}
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
${plan.price.toFixed(2)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{plan.features.map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-2">
<Button
isLoading={loading === `trial:${plan.tier}`}
disabled={loading !== null}
onClick={() => handleTrial(plan.tier)}
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Hobby</p>
<p className="text-sm text-muted-foreground mt-1">
For individual developers
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
${calculatePriceHobby(1, false).toFixed(2)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
"Setup 1 server",
"Unlimited apps & databases",
"2 environments",
"Community support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
Start 7-day free trial
<ArrowRightIcon className="size-4" />
</Button>
<Button
variant="outline"
isLoading={loading === `checkout:${plan.tier}`}
disabled={loading !== null || !productId}
onClick={() => handleCheckout(plan.tier)}
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
variant="outline"
isLoading={loadingTier === "hobby"}
disabled={loadingTier !== null || !data?.hobbyProductId}
onClick={() => handleCheckout("hobby")}
>
Subscribe
</Button>
</div>
<div className="flex flex-col justify-between gap-6 bg-background p-7">
<div>
<p className="font-medium">Startup</p>
<p className="text-sm text-muted-foreground mt-1">
For small to mid-size teams
</p>
<p className="text-3xl font-semibold mt-4 tabular-nums">
$
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
2,
)}
<span className="text-sm font-normal text-muted-foreground">
{" "}
/mo
</span>
</p>
<ul className="flex flex-col gap-1.5 mt-4">
{[
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
"Unlimited users & environments",
"Basic RBAC + 2FA",
"Email & chat support",
].map((f) => (
<li
key={f}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
Subscribe now
</Button>
</div>
</div>
);
})}
<CheckIcon className="size-3.5 shrink-0" />
{f}
</li>
))}
</ul>
</div>
<Button
variant="outline"
isLoading={loadingTier === "startup"}
disabled={loadingTier !== null || !data?.startupProductId}
onClick={() => handleCheckout("startup")}
>
Subscribe
</Button>
</div>
</div>
</div>
);
@@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {
</span>
<h1 className={titleClassName}>Create your first project.</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Projects group your apps, databases, and environments together.
Projects group your apps, databases and environments together.
</p>
</div>
@@ -1,12 +1,6 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard";
import {
CheckCircle2,
CopyIcon,
InfoIcon,
Loader2,
XCircle,
} from "lucide-react";
import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -206,19 +200,9 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
</span>
<h1 className={titleClassName}>Connect a server.</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
Dokploy deploys to servers you own. Buy one from any VPS provider
Dokploy deploys to servers you own — buy one from any VPS provider
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
</p>
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">Requirements</span>
<span className="text-sm text-muted-foreground">
To ensure a smooth experience with Dokploy, your server should
have at least 1GB of RAM and 30GB of disk space.
</span>
</div>
</div>
</div>
{!createdServerId ? (
@@ -303,7 +287,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
{isSettingUp ? (
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin shrink-0" />
Setting up your server – installing Docker and dependencies…
Setting up your server — installing Docker and dependencies...
</div>
) : isValidating && !validation ? (
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
@@ -327,7 +311,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
</div>
{!isReady && !isSettingUp && (
<AlertBlock type="info">
Setup runs after the server boots and this page checks for it
Setup usually takes a minute after the server boots — this checks
automatically. You can retry setup, or use "Skip for now" above to
continue and come back to this later.
</AlertBlock>
@@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => {
Let's get your first app <em className="not-italic">live</em>.
</h1>
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
It just takes a few steps: pick a plan, connect a server, ship
something. You'll have a working URL by the end.
A few steps — pick a plan, connect a server, ship something. You'll
have a working URL by the end.
</p>
</div>
@@ -1,4 +1,4 @@
import { CreditCard, FileText, Server } from "lucide-react";
import { CreditCard, FileText } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
@@ -17,11 +17,6 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
@@ -10,7 +10,6 @@ import {
Loader2,
MinusIcon,
PlusIcon,
Server,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
@@ -84,11 +83,6 @@ const navigationItems = [
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
@@ -111,53 +105,13 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const { mutateAsync: startFreeTrial } =
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
api.stripe.startFreeTrial.useMutation();
const utils = api.useUtils();
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
const [switchingTrial, setSwitchingTrial] = useState(false);
const handleSwitchTrialPlan = async (
tier: "hobby" | "startup",
isAnnualPlan: boolean,
label?: string,
) => {
const planName =
label ??
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
const switchPlan = async () => {
await upgradeSubscription({
tier,
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
isAnnual: isAnnualPlan,
});
await new Promise((resolve) => setTimeout(resolve, 3000));
await Promise.all([
utils.stripe.getBillingStatus.invalidate(),
utils.stripe.getProducts.invalidate(),
utils.user.get.invalidate(),
utils.server.count.invalidate(),
]);
};
setSwitchingTrial(true);
await toast
.promise(switchPlan(), {
loading: `Switching to ${planName}...`,
success: `Your trial is now on ${planName}`,
error: (error) =>
error instanceof Error ? error.message : "Error switching plan",
})
.unwrap()
.catch(() => undefined);
setSwitchingTrial(false);
};
const handleStartTrial = async (tier: "hobby" | "startup") => {
setTrialTier(tier);
const handleStartTrial = async () => {
try {
await startFreeTrial({ tier });
await startFreeTrial();
await Promise.all([
utils.stripe.getBillingStatus.invalidate(),
utils.stripe.getProducts.invalidate(),
@@ -168,8 +122,6 @@ export const ShowBilling = () => {
toast.error(
error instanceof Error ? error.message : "Error starting trial",
);
} finally {
setTrialTier(null);
}
};
@@ -218,23 +170,12 @@ export const ShowBilling = () => {
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
const products = data?.products.filter((product) => {
// @ts-expect-error
// @ts-ignore
const interval = product?.default_price?.recurring?.interval;
return isAnnual ? interval === "year" : interval === "month";
});
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
const canStartTrial =
!isEnterpriseCloud &&
!!billingStatus &&
!billingStatus.plan &&
!billingStatus.isOnTrial &&
!billingStatus.hasUsedTrial;
/** Checkout would create a second subscription alongside a running trial. */
const canSubscribe =
!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 &&
!billingStatus?.isOnTrial;
const maxServers = admin?.user.serversQuantity ?? 1;
const percentage = ((servers ?? 0) / maxServers) * 100;
const safePercentage = Math.min(percentage, 100);
@@ -348,11 +289,6 @@ export const ShowBilling = () => {
<Badge className="capitalize" variant="secondary">
{billingStatus.plan ?? "Trial"}
</Badge>
{billingStatus.plan && (
<Badge variant="outline">
{billingStatus.isAnnual ? "Annual" : "Monthly"}
</Badge>
)}
</div>
<span className="text-sm text-muted-foreground">
{billingStatus.isOnTrial
@@ -360,94 +296,67 @@ export const ShowBilling = () => {
billingStatus.trialEndsAt
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
: ""
}${
billingStatus.hasPaymentMethod
? " · billing starts automatically"
: " · add a card to keep your servers"
}`
: "You're subscribed and billed automatically."}
</span>
</div>
</div>
{admin?.user.stripeCustomerId && (
<div className="flex flex-wrap items-center gap-2">
{billingStatus.isOnTrial &&
useNewPricing &&
billingStatus.plan &&
billingStatus.plan !== "legacy" && (
<>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan === "startup"
? "hobby"
: "startup",
billingStatus.isAnnual,
)
}
>
{billingStatus.plan === "startup"
? "Switch to Hobby"
: "Switch to Startup"}
</Button>
<Button
size="sm"
variant="outline"
isLoading={switchingTrial}
onClick={() =>
handleSwitchTrialPlan(
billingStatus.plan as "hobby" | "startup",
!billingStatus.isAnnual,
billingStatus.isAnnual
? "monthly billing"
: "annual billing",
)
}
>
{billingStatus.isAnnual
? "Switch to monthly"
: "Switch to annual"}
</Button>
</>
)}
{billingStatus.isOnTrial &&
admin?.user.stripeCustomerId && (
<Button
size="sm"
variant={
billingStatus.isOnTrial &&
!billingStatus.hasPaymentMethod
? "default"
: "secondary"
}
variant="outline"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
{billingStatus.isOnTrial &&
!billingStatus.hasPaymentMethod
? "Add payment method"
: "Manage Subscription"}
Add payment method
</Button>
</div>
)}
)}
</div>
)}
{canStartTrial && (
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col">
<span className="text-sm font-medium">
7-day free trial on any plan
</span>
<span className="text-sm text-muted-foreground">
No credit card required — pick Hobby or Startup below.
</span>
{!isEnterpriseCloud &&
billingStatus &&
!billingStatus.plan &&
!billingStatus.isOnTrial &&
!billingStatus.hasUsedTrial && (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
7-day free trial
</span>
<span className="text-sm text-muted-foreground">
No credit card required — cancel anytime.
</span>
<ul className="flex flex-col gap-1 mt-1">
{[
"Setup 1 server",
"Unlimited apps & databases",
"Community support",
].map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<CheckIcon className="size-3.5 shrink-0" />
{feature}
</li>
))}
</ul>
</div>
</div>
<Button
size="sm"
isLoading={isStartingTrial}
onClick={handleStartTrial}
>
Start trial
</Button>
</div>
</div>
)}
)}
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<div className="space-y-2 flex flex-col">
<h3 className="text-lg font-medium">Servers Plan</h3>
@@ -1024,71 +933,68 @@ export const ShowBilling = () => {
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
{canSubscribe && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Servers:
</span>
<Button
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) =>
Math.max(1, q - 1),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={hobbyServerQuantity}
onChange={(e) =>
setHobbyServerQuantity(
Math.max(
1,
Number(
(e.target as HTMLInputElement).value,
) || 1,
),
)
}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Servers:
</span>
<Button
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setHobbyServerQuantity((q) => Math.max(1, q - 1))
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={hobbyServerQuantity}
onChange={(e) =>
setHobbyServerQuantity(
Math.max(
1,
Number(
(e.target as HTMLInputElement).value,
) || 1,
),
)
}
className="text-center"
/>
<Button
variant="outline"
size="icon"
onClick={() => setHobbyServerQuantity((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-col gap-2 w-full">
{canStartTrial && (
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
isLoading={trialTier === "hobby"}
disabled={trialTier !== null}
onClick={() => handleStartTrial("hobby")}
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Start 7-day free trial
</Button>
)}
{canSubscribe && (
<Button
className="w-full"
variant={canStartTrial ? "outline" : "default"}
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -1161,83 +1067,85 @@ export const ShowBilling = () => {
))}
</ul>
<div className="mt-6 flex flex-col gap-3">
{canSubscribe && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground">
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
</span>
<div className="flex items-center gap-2">
<Button
disabled={
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={startupServerQuantity}
onChange={(e) =>
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
(e.target as HTMLInputElement).value,
) || STARTUP_SERVERS_INCLUDED,
),
)
}
className="h-8 text-center"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-2 w-full">
{canStartTrial && (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground">
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
</span>
<div className="flex items-center gap-2">
<Button
className="w-full"
isLoading={trialTier === "startup"}
disabled={trialTier !== null}
onClick={() => handleStartTrial("startup")}
>
Start 7-day free trial
</Button>
)}
{canSubscribe && (
<Button
className="w-full"
variant={canStartTrial ? "outline" : "default"}
disabled={
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
disabled={
startupServerQuantity < STARTUP_SERVERS_INCLUDED
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={startupServerQuantity}
onChange={(e) =>
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
(e.target as HTMLInputElement).value,
) || STARTUP_SERVERS_INCLUDED,
),
)
}
className="h-8 text-center"
/>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
Get Started
<PlusIcon className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-col gap-2 w-full">
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={() =>
handleCheckout(
"startup",
data!.startupProductId!,
)
}
disabled={
startupServerQuantity <
STARTUP_SERVERS_INCLUDED
}
>
Get Started
</Button>
)}
</div>
</div>
</section>
@@ -1429,17 +1337,31 @@ export const ShowBilling = () => {
</Button>
</div>
<div className="flex flex-col gap-2 mt-4 w-full">
{canSubscribe && (
{admin?.user.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
const session =
await createCustomerPortalSession();
window.open(session.url);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
Manage Subscription
</Button>
)}
{!isEnterpriseCloud &&
(data?.subscriptions?.length ?? 0) === 0 && (
<Button
className="w-full"
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>
)}
</div>
</div>
</section>
@@ -1,502 +0,0 @@
import {
AlertCircle,
CheckCircle2,
Clock,
CreditCard,
ExternalLink,
FileText,
Loader2,
Plus,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Managed Servers",
href: "/dashboard/settings/managed-servers",
icon: Server,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
const STATUS_MAP: Record<
string,
{
label: string;
icon: React.ReactNode;
variant: "default" | "secondary" | "destructive" | "outline";
}
> = {
pending: {
label: "Pending",
icon: <Clock className="size-3" />,
variant: "secondary",
},
provisioning: {
label: "Provisioning",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
configuring: {
label: "Installing Dokploy",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
ready: {
label: "Ready",
icon: <CheckCircle2 className="size-3" />,
variant: "default",
},
error: {
label: "Error",
icon: <XCircle className="size-3" />,
variant: "destructive",
},
terminating: {
label: "Terminating",
icon: <Loader2 className="size-3 animate-spin" />,
variant: "secondary",
},
terminated: {
label: "Terminated",
icon: <AlertCircle className="size-3" />,
variant: "outline",
},
};
function formatSpecs(cpus: number, memoryGb: number, storageGb: number) {
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
}
function formatPrice(priceCents: number) {
return `$${(priceCents / 100).toFixed(2)}/mo`;
}
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
const [open, setOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>("");
const [selectedZone, setSelectedZone] = useState<string>("");
const { data: plans, isLoading: loadingPlans } =
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
const { data: zones, isLoading: loadingZones } =
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
const isLoadingOptions = loadingPlans || loadingZones;
const purchase = api.managedServer.purchase.useMutation({
onSuccess: () => {
toast.success("Server order placed! Provisioning will take ~5 minutes.");
setOpen(false);
onSuccess();
},
onError: (err) => {
toast.error(err.message);
},
});
const plan = plans?.find((p) => p.id === selectedPlan);
const groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
if (!acc[z.continent]) acc[z.continent] = [];
acc[z.continent]!.push(z);
return acc;
}, {});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="size-4 mr-2" />
Order Server
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Order a Managed Server</DialogTitle>
<DialogDescription>
We'll provision and configure a server for you automatically. Ready
in ~5 minutes.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{isLoadingOptions ? (
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<p className="text-sm">Loading available plans...</p>
</div>
) : (
<div className="space-y-5">
{/* Plan cards */}
<div className="space-y-2">
<Label>Plan</Label>
<div className="grid grid-cols-2 gap-2">
{plans?.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelectedPlan(p.id)}
className={cn(
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
selectedPlan === p.id
? "border-primary bg-primary/5"
: "border-border hover:border-muted-foreground",
)}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-sm">{p.name}</p>
<p className="text-sm font-medium text-primary">
{formatPrice(p.priceCents)}
</p>
</div>
<p className="text-xs text-muted-foreground">
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
</p>
</button>
))}
</div>
</div>
{/* Zone selector grouped by continent */}
<div className="space-y-2">
<Label>Location</Label>
<Select value={selectedZone} onValueChange={setSelectedZone}>
<SelectTrigger>
<SelectValue placeholder="Select a location..." />
</SelectTrigger>
<SelectContent
position="popper"
side="bottom"
sideOffset={4}
className="max-h-64 overflow-y-auto"
>
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
<div key={continent}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{continent}
</div>
{groupedZones?.[continent]?.map((z) => (
<SelectItem key={z.id} value={z.id}>
{z.description}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
{plan && selectedZone && (
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Plan</span>
<span className="font-medium">{plan.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Specs</span>
<span className="font-medium">
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Price</span>
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Zone</span>
<span className="font-medium">
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
</span>
</div>
</div>
)}
<Button
className="w-full"
disabled={!selectedPlan || !selectedZone || purchase.isPending}
onClick={() => {
if (!selectedPlan || !selectedZone) return;
purchase.mutate({
plan: selectedPlan,
zone: selectedZone,
});
}}
>
{purchase.isPending ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Placing order...
</>
) : (
"Order Server"
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
const PLAN_LABELS: Record<string, string> = {
hobby: "Hobby",
starter: "Starter",
pro: "Pro",
business: "Business",
};
export const ShowManagedServers = () => {
const router = useRouter();
const utils = api.useUtils();
const { data: servers, isLoading } = api.managedServer.list.useQuery();
const syncStatus = api.managedServer.syncStatus.useMutation({
onSuccess: () => utils.managedServer.list.invalidate(),
});
const reconnect = api.managedServer.reconnect.useMutation({
onSuccess: () => {
toast.success("Server reconnected successfully.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
const deleteServer = api.managedServer.delete.useMutation({
onSuccess: () => {
toast.success("Server terminated.");
utils.managedServer.list.invalidate();
},
onError: (err) => toast.error(err.message),
});
return (
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Server className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and servers
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">
{navigationItems.map((item) => {
const Icon = item.icon;
const isActive = router.pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
isActive
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
)}
>
<Icon className="h-4 w-4" />
{item.name}
</Link>
);
})}
</nav>
<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-base">Managed Servers</h3>
<p className="text-sm text-muted-foreground">
Servers provisioned and managed by Dokploy Cloud
</p>
</div>
<OrderServerDialog
onSuccess={() => utils.managedServer.list.invalidate()}
/>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : servers?.length === 0 ? (
<div className="text-center py-12 border rounded-lg border-dashed">
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
<p className="text-sm font-medium">No managed servers yet</p>
<p className="text-xs text-muted-foreground mt-1">
Order a server and we'll provision and configure it for you
automatically.
</p>
</div>
) : (
<div className="space-y-3">
{servers?.map((s) => {
const status =
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
const isProvisioning = [
"pending",
"provisioning",
"configuring",
].includes(s.status);
return (
<div
key={s.managedServerId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
<Server className="size-5 text-muted-foreground shrink-0" />
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{PLAN_LABELS[s.plan] ?? s.plan}
</span>
<Badge
variant={status?.variant}
className="flex items-center gap-1 text-xs h-5"
>
{status?.icon}
{status?.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{s.hostname ?? ""}
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
{s.zone ? ` · ${s.zone}` : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isProvisioning && (
<Button
variant="ghost"
size="sm"
onClick={() =>
syncStatus.mutate({
managedServerId: s.managedServerId,
})
}
disabled={syncStatus.isPending}
>
<Loader2
className={cn(
"size-4",
syncStatus.isPending && "animate-spin",
)}
/>
</Button>
)}
{s.status === "ready" && !s.serverId && (
<Button
variant="outline"
size="sm"
onClick={() =>
reconnect.mutate({
managedServerId: s.managedServerId,
})
}
disabled={reconnect.isPending}
>
{reconnect.isPending ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1.5" />
)}
Reconnect
</Button>
)}
{s.status === "ready" && s.server && (
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/settings/server?serverId=${s.serverId}`}
>
<ExternalLink className="size-3.5 mr-1.5" />
Open
</Link>
</Button>
)}
<DialogAction
title="Terminate Server"
description="This will permanently destroy the server and all data on it. This action cannot be undone."
type="destructive"
onClick={() =>
deleteServer.mutate({
managedServerId: s.managedServerId,
})
}
>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</div>
</Card>
</div>
);
};
@@ -1,22 +0,0 @@
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
CREATE TABLE "managed_server" (
"managedServerId" text PRIMARY KEY NOT NULL,
"organizationId" text NOT NULL,
"serverId" text,
"plan" text NOT NULL,
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
"providerVmId" text,
"zone" text NOT NULL,
"sshKeyId" text,
"ipAddress" text,
"hostname" text,
"stripeSubscriptionId" text,
"stripePriceId" text,
"errorMessage" text,
"createdAt" text NOT NULL,
"updatedAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1380,13 +1380,6 @@
"when": 1788995453103,
"tag": "0196_worthless_ravenous",
"breakpoints": true
},
{
"idx": 197,
"version": "7",
"when": 1790609715152,
"tag": "0197_aberrant_psynapse",
"breakpoints": true
}
]
}
+16 -39
View File
@@ -9,7 +9,6 @@ import {
sendInvoiceEmail,
sendPaymentFailedEmail,
} from "@/server/utils/stripe-notifications";
import { sendTrialExpiringEmail } from "@/server/utils/trial-notifications";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -73,7 +72,6 @@ export default async function handler(
"invoice.payment_failed",
"customer.deleted",
"checkout.session.completed",
"customer.subscription.trial_will_end",
];
if (!webhooksAllowed.includes(event.type)) {
@@ -112,12 +110,6 @@ export default async function handler(
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
break;
}
case "customer.subscription.trial_will_end": {
const trialingSubscription = event.data.object as Stripe.Subscription;
await sendTrialExpiringEmail(stripe, trialingSubscription, event.created);
break;
}
case "customer.subscription.created": {
const newSubscription = event.data.object as Stripe.Subscription;
@@ -184,10 +176,7 @@ export default async function handler(
break;
}
if (
newSubscription.status === "active" ||
newSubscription.status === "trialing"
) {
if (newSubscription.status === "active") {
const serversQuantity = getSubscriptionServersQuantity(
newSubscription?.items?.data ?? [],
);
@@ -220,12 +209,9 @@ export default async function handler(
case "invoice.payment_succeeded": {
const newInvoice = event.data.object as Stripe.Invoice;
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
if (!subscriptionId) {
break;
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const subscription = await stripe.subscriptions.retrieve(
newInvoice.subscription as string,
);
if (subscription.status !== "active") {
console.log(
@@ -234,14 +220,6 @@ export default async function handler(
break;
}
const admin = await findUserByStripeCustomerId(
subscription.customer as string,
);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
const serversQuantity = getSubscriptionServersQuantity(
subscription?.items?.data ?? [],
);
@@ -255,10 +233,18 @@ export default async function handler(
),
);
const admin = await findUserByStripeCustomerId(
subscription.customer as string,
);
if (!admin) {
return res.status(400).send("Webhook Error: Admin not found");
}
if (admin.isEnterpriseCloud) {
break;
}
await updateServersBasedOnQuantity(admin.id, serversQuantity);
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
if (admin.sendInvoiceNotifications) {
await sendInvoiceEmail(newInvoice, admin);
@@ -269,12 +255,9 @@ export default async function handler(
case "invoice.payment_failed": {
const newInvoice = event.data.object as Stripe.Invoice;
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
if (!subscriptionId) {
break;
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const subscription = await stripe.subscriptions.retrieve(
newInvoice.subscription as string,
);
if (subscription.status !== "active") {
const admin = await findUserByStripeCustomerId(
newInvoice.customer as string,
@@ -368,12 +351,6 @@ const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
return userResult;
};
const getInvoiceSubscriptionId = (invoice: Stripe.Invoice): string | null => {
const subscription = invoice.subscription;
if (typeof subscription === "string") return subscription;
return subscription?.id ?? null;
};
const activateServer = async (serverId: string) => {
await db
.update(server)
@@ -1,39 +0,0 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
const Page = () => {
return <ShowManagedServers />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
return { props: {} };
}
-2
View File
@@ -38,7 +38,6 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { managedServerRouter } from "./routers/proprietary/managed-server";
import { scimRouter } from "./routers/proprietary/scim";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -123,7 +122,6 @@ export const appRouter = createTRPCRouter({
patch: patchRouter,
overview: overviewRouter,
transfer: transferRouter,
managedServer: managedServerRouter,
});
// export type definition of API
@@ -1,333 +0,0 @@
import {
createServer,
IS_CLOUD,
serverSetup,
} from "@dokploy/server";
import {
apiCreateManagedServer,
apiDeleteManagedServer,
apiFindOneManagedServer,
} from "@dokploy/server/db/schema/managed-server";
import {
createManagedServer,
deleteManagedServer,
findManagedServerById,
findManagedServersByOrg,
updateManagedServer,
} from "@dokploy/server/services/managed-server";
import { createSshKey } from "@dokploy/server/services/ssh-key";
import { generateSSHKey } from "@dokploy/server/utils/filesystem/ssh";
import {
DOKPLOY_PLANS,
createUpCloudServer,
deleteUpCloudServer,
getPublicIPv4,
getUpCloudServer,
getUpCloudZones,
stopUpCloudServer,
} from "@dokploy/server/utils/upcloud";
import { TRPCError } from "@trpc/server";
import { nanoid } from "nanoid";
import { adminProcedure, createTRPCRouter } from "../../trpc";
export const managedServerRouter = createTRPCRouter({
getPlans: adminProcedure.query(async () => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
return DOKPLOY_PLANS;
}),
getDataCenters: adminProcedure.query(async () => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
return getUpCloudZones();
}),
list: adminProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return [];
return findManagedServersByOrg(ctx.session.activeOrganizationId);
}),
one: adminProcedure
.input(apiFindOneManagedServer)
.query(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return record;
}),
purchase: adminProcedure
.input(apiCreateManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Managed servers are only available in Dokploy Cloud",
});
}
const plan = DOKPLOY_PLANS.find((p) => p.id === input.plan);
if (!plan) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
}
const hostname =
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
const managedRecord = await createManagedServer({
organizationId: ctx.session.activeOrganizationId,
plan: input.plan,
zone: input.zone,
status: "provisioning",
});
provisionManagedServer(
managedRecord.managedServerId,
plan.upcloudPlan,
input.zone,
hostname,
ctx.session.activeOrganizationId,
).catch(async (err) => {
const responseBody = err?.response?.data;
const detail =
responseBody?.message ??
responseBody?.error ??
(typeof responseBody === "string" ? responseBody : null);
const errorMessage = detail
? `${err?.message}: ${typeof detail === "object" ? JSON.stringify(detail) : detail}`
: (err?.message ?? "Unknown error during provisioning");
console.error("[managed-server] provisioning failed:", errorMessage, responseBody);
await updateManagedServer(managedRecord.managedServerId, {
status: "error",
errorMessage,
});
});
return managedRecord;
}),
delete: adminProcedure
.input(apiDeleteManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
await updateManagedServer(input.managedServerId, {
status: "terminating",
});
const upcloudUuid = record.providerVmId;
terminateManagedServer(input.managedServerId, upcloudUuid ?? null).catch(
(err) => console.error("[managed-server] termination failed:", err),
);
return { ok: true };
}),
reconnect: adminProcedure
.input(apiFindOneManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (record.serverId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Server is already linked",
});
}
if (!record.ipAddress) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No IP address available to reconnect",
});
}
if (!record.sshKeyId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No SSH key found for this managed server",
});
}
const name = record.hostname ?? record.managedServerId;
const serverRecord = await createServer(
{
name: `Managed • ${name}`,
description: "Managed server provisioned by Dokploy Cloud",
ipAddress: record.ipAddress,
port: 22,
username: "root",
serverType: "deploy",
enableDockerCleanup: true,
sshKeyId: record.sshKeyId,
},
ctx.session.activeOrganizationId,
);
await updateManagedServer(input.managedServerId, {
serverId: serverRecord.serverId,
status: "ready",
});
return findManagedServerById(input.managedServerId);
}),
syncStatus: adminProcedure
.input(apiFindOneManagedServer)
.mutation(async ({ input, ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
}
const record = await findManagedServerById(input.managedServerId);
if (record.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const upcloudUuid = record.providerVmId;
if (!upcloudUuid) return record;
const vm = await getUpCloudServer(upcloudUuid);
const ipAddress = getPublicIPv4(vm) ?? record.ipAddress ?? undefined;
await updateManagedServer(input.managedServerId, {
ipAddress,
hostname: vm.hostname ?? undefined,
status:
vm.state === "started"
? record.serverId
? "ready"
: "configuring"
: record.status,
});
return findManagedServerById(input.managedServerId);
}),
});
async function provisionManagedServer(
managedServerId: string,
upcloudPlan: string,
zone: string,
hostname: string,
organizationId: string,
) {
const { publicKey, privateKey } = await generateSSHKey("rsa");
const vm = await createUpCloudServer({
hostname,
upcloudPlan,
zone,
sshKey: publicKey,
});
await updateManagedServer(managedServerId, {
providerVmId: vm.uuid,
hostname: vm.hostname,
status: "configuring",
});
await waitForServerStarted(vm.uuid, managedServerId);
const finalVm = await getUpCloudServer(vm.uuid);
const finalIp = getPublicIPv4(finalVm);
if (!finalIp) {
throw new Error("VM is started but has no public IPv4 address");
}
const sshKey = await createSshKey({
name: hostname,
publicKey,
privateKey,
organizationId,
});
if (!sshKey) throw new Error("Failed to create SSH key");
const serverRecord = await createServer(
{
name: `Managed • ${hostname}`,
description: "Managed server provisioned by Dokploy Cloud",
ipAddress: finalIp,
port: 22,
username: "root",
serverType: "deploy",
enableDockerCleanup: true,
sshKeyId: sshKey.sshKeyId,
},
organizationId,
);
await updateManagedServer(managedServerId, {
serverId: serverRecord.serverId,
sshKeyId: sshKey.sshKeyId,
ipAddress: finalIp,
});
await serverSetup(serverRecord.serverId);
await updateManagedServer(managedServerId, { status: "ready" });
}
async function waitForServerStarted(
uuid: string,
_managedServerId: string,
maxAttempts = 30,
intervalMs = 10_000,
) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, intervalMs));
const vm = await getUpCloudServer(uuid);
if (vm.state === "started") return;
if (vm.state === "error") throw new Error("VM entered error state");
}
throw new Error("Timed out waiting for VM to start");
}
async function waitForServerStopped(
uuid: string,
maxAttempts = 18,
intervalMs = 10_000,
) {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, intervalMs));
const vm = await getUpCloudServer(uuid);
if (vm.state === "stopped") return;
}
}
async function terminateManagedServer(
managedServerId: string,
upcloudUuid: string | null,
) {
if (upcloudUuid) {
await stopUpCloudServer(upcloudUuid);
await waitForServerStopped(upcloudUuid);
await deleteUpCloudServer(upcloudUuid);
}
await deleteManagedServer(managedServerId);
}
@@ -5,6 +5,7 @@ import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import {
getOrganizationOwnerId,
markOrganizationOwnerVerified,
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
@@ -212,6 +213,7 @@ export const ssoRouter = createTRPCRouter({
body: updateBody,
headers: requestToHeaders(ctx.req),
});
await markOrganizationOwnerVerified(ctx.session.activeOrganizationId);
return { success: true };
}),
deleteProvider: enterpriseProcedure
@@ -292,6 +294,7 @@ export const ssoRouter = createTRPCRouter({
},
headers: requestToHeaders(ctx.req),
});
await markOrganizationOwnerVerified(organizationId);
return { success: true };
}),
addTrustedOrigin: enterpriseProcedure
@@ -19,6 +19,7 @@ import {
getUpdateData,
getWebServerSettings,
IS_CLOUD,
markOrganizationOwnerVerified,
parseRawConfig,
paths,
prepareEnvironmentVariables,
@@ -467,6 +468,9 @@ export const settingsRouter = createTRPCRouter({
await updateWebServerSettings({
enforceSSO: input.enforceSSO,
});
if (input.enforceSSO) {
await markOrganizationOwnerVerified(ctx.session.activeOrganizationId);
}
await audit(ctx, {
action: "update",
@@ -6,7 +6,6 @@ import {
updateSSHKeyById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { managedServer } from "@dokploy/server/db/schema/managed-server";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import {
@@ -58,16 +57,6 @@ export const sshRouter = createTRPCRouter({
});
}
const linkedManagedServer = await db.query.managedServer.findFirst({
where: eq(managedServer.sshKeyId, input.sshKeyId),
});
if (linkedManagedServer) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This SSH key is used by a managed server and cannot be deleted.",
});
}
await audit(ctx, {
action: "delete",
resourceType: "sshKey",
+65 -84
View File
@@ -12,7 +12,7 @@ import {
getCurrentPlan as getCurrentPlanForOrganization,
getStripeClient,
TRIAL_DURATION_DAYS,
TRIAL_SERVER_LIMITS,
TRIAL_SERVER_LIMIT,
} from "@/server/utils/billing";
import {
type BillingTier,
@@ -45,82 +45,72 @@ export const stripeRouter = createTRPCRouter({
return getBillingStatus(ctx.user.ownerId);
}),
startFreeTrial: adminProcedure
.input(z.object({ tier: z.enum(["hobby", "startup"]) }))
.mutation(async ({ ctx, input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
const trialPriceId =
input.tier === "startup"
? STARTUP_BASE_PRICE_MONTHLY_ID
: HOBBY_PRICE_MONTHLY_ID;
if (!trialPriceId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Trials are not configured",
});
}
const owner = await findUserById(ctx.user.ownerId);
const billingStatus = await getBillingStatus(owner.id);
if (billingStatus.hasActiveAccess) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You already have an active plan or trial",
});
}
if (billingStatus.hasUsedTrial) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You have already used your free trial",
});
}
const stripe = getStripeClient();
let stripeCustomerId = owner.stripeCustomerId;
if (stripeCustomerId) {
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted) {
stripeCustomerId = null;
}
}
if (!stripeCustomerId) {
const customer = await stripe.customers.create({ email: owner.email });
stripeCustomerId = customer.id;
}
const subscription = await stripe.subscriptions.create({
customer: stripeCustomerId,
items: [{ price: trialPriceId, quantity: 1 }],
trial_period_days: TRIAL_DURATION_DAYS,
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
metadata: {
source: "onboarding_trial",
adminId: owner.id,
tier: input.tier,
},
startFreeTrial: adminProcedure.mutation(async ({ ctx }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
await updateUser(owner.id, {
stripeCustomerId,
stripeSubscriptionId: subscription.id,
serversQuantity: TRIAL_SERVER_LIMITS[input.tier],
if (!HOBBY_PRICE_MONTHLY_ID) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Trials are not configured",
});
}
return {
trialEndsAt: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
};
}),
const owner = await findUserById(ctx.user.ownerId);
const billingStatus = await getBillingStatus(owner.id);
if (billingStatus.hasActiveAccess) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You already have an active plan or trial",
});
}
if (billingStatus.hasUsedTrial) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You have already used your free trial",
});
}
const stripe = getStripeClient();
let stripeCustomerId = owner.stripeCustomerId;
if (stripeCustomerId) {
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted) {
stripeCustomerId = null;
}
}
if (!stripeCustomerId) {
const customer = await stripe.customers.create({ email: owner.email });
stripeCustomerId = customer.id;
}
const subscription = await stripe.subscriptions.create({
customer: stripeCustomerId,
items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }],
trial_period_days: TRIAL_DURATION_DAYS,
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
metadata: { source: "onboarding_trial", adminId: owner.id },
});
await updateUser(owner.id, {
stripeCustomerId,
stripeSubscriptionId: subscription.id,
serversQuantity: TRIAL_SERVER_LIMIT,
});
return {
trialEndsAt: subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null,
};
}),
getProducts: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
@@ -353,15 +343,9 @@ export const stripeRouter = createTRPCRouter({
});
}
const isTrialing = subscription.status === "trialing";
// Trials are capped at the plan's included servers; paid plans can scale.
const serverQuantity = isTrialing
? TRIAL_SERVER_LIMITS[input.tier]
: input.serverQuantity;
const newItems = getStripeItems(
input.tier as BillingTier,
serverQuantity,
input.serverQuantity,
input.isAnnual,
);
const currentItems = subscription.items.data;
@@ -387,10 +371,7 @@ export const stripeRouter = createTRPCRouter({
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
items: updateItems,
proration_behavior: isTrialing ? "none" : "create_prorations",
...(isTrialing && subscription.trial_end
? { trial_end: subscription.trial_end }
: {}),
proration_behavior: "create_prorations",
});
return { ok: true };
+3 -24
View File
@@ -16,7 +16,7 @@ export const getStripeClient = () =>
apiVersion: "2024-09-30.acacia",
});
export const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
if (
priceIds.some(
(id) =>
@@ -78,11 +78,7 @@ export const getCurrentPlan = async (
};
export const TRIAL_DURATION_DAYS = 7;
export type TrialTier = "hobby" | "startup";
export const TRIAL_SERVER_LIMITS: Record<TrialTier, number> = {
hobby: 1,
startup: 3,
};
export const TRIAL_SERVER_LIMIT = 1;
export interface BillingStatus {
plan: BillingPlan | null;
@@ -91,8 +87,6 @@ export interface BillingStatus {
trialDaysRemaining: number | null;
hasUsedTrial: boolean;
hasActiveAccess: boolean;
hasPaymentMethod: boolean;
isAnnual: boolean;
}
export const getBillingStatus = async (
@@ -106,8 +100,6 @@ export const getBillingStatus = async (
trialDaysRemaining: null,
hasUsedTrial: false,
hasActiveAccess: true,
hasPaymentMethod: true,
isAnnual: false,
};
}
@@ -120,8 +112,6 @@ export const getBillingStatus = async (
trialDaysRemaining: null,
hasUsedTrial: false,
hasActiveAccess: false,
hasPaymentMethod: false,
isAnnual: false,
};
}
@@ -129,7 +119,7 @@ export const getBillingStatus = async (
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "all",
expand: ["data.items.data.price", "data.customer"],
expand: ["data.items.data.price"],
});
const relevantSubs = subscriptions.data.filter(
@@ -143,15 +133,6 @@ export const getBillingStatus = async (
const trialingSub = subscriptions.data.find(
(sub) => sub.status === "trialing",
);
const currentSub = trialingSub ?? relevantSubs[0];
const customer = currentSub?.customer as Stripe.Customer | undefined;
const hasPaymentMethod =
!!currentSub?.default_payment_method ||
!!customer?.invoice_settings?.default_payment_method;
const isAnnual =
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
?.interval === "year";
const trialEndsAt = trialingSub?.trial_end
? new Date(trialingSub.trial_end * 1000)
: null;
@@ -167,9 +148,7 @@ export const getBillingStatus = async (
isOnTrial: !!trialingSub,
trialEndsAt,
trialDaysRemaining,
hasPaymentMethod,
hasUsedTrial: subscriptions.data.length > 0,
hasActiveAccess: plan !== null || !!trialingSub,
isAnnual,
};
};
@@ -1,78 +0,0 @@
import { db } from "@dokploy/server/db";
import TrialExpiringEmail from "@dokploy/server/emails/emails/trial-expiring";
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
import { render } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
import { user } from "@/server/db/schema";
import { planFromPriceIds } from "@/server/utils/billing";
import { WEBSITE_URL } from "@/server/utils/stripe";
const PLAN_LABELS: Record<string, string> = {
hobby: "Hobby",
startup: "Startup",
legacy: "Legacy",
};
const customerIdOf = (subscription: Stripe.Subscription) =>
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
const hasPaymentMethod = async (
stripe: Stripe,
subscription: Stripe.Subscription,
) => {
if (subscription.default_payment_method) return true;
const customer = await stripe.customers.retrieve(customerIdOf(subscription));
if (customer.deleted) return true;
return !!customer.invoice_settings?.default_payment_method;
};
export const sendTrialExpiringEmail = async (
stripe: Stripe,
subscription: Stripe.Subscription,
// Stripe retries a failing webhook for days, so the countdown is measured
// from when Stripe emitted the event rather than from the current time.
notifiedAtSeconds: number,
) => {
if (!subscription.trial_end) return false;
if (await hasPaymentMethod(stripe, subscription)) return false;
const owner = await db.query.user.findFirst({
where: eq(user.stripeCustomerId, customerIdOf(subscription)),
});
if (!owner || owner.isEnterpriseCloud) return false;
const trialEndsAt = new Date(subscription.trial_end * 1000);
const daysRemaining = Math.max(
1,
Math.ceil((subscription.trial_end - notifiedAtSeconds) / (60 * 60 * 24)),
);
const plan = planFromPriceIds(
subscription.items.data.map((item) => (item.price as Stripe.Price).id),
);
const htmlContent = await render(
TrialExpiringEmail({
userName: owner.firstName || "User",
planName: plan ? (PLAN_LABELS[plan] ?? plan) : "Trial",
daysRemaining,
endsOn: format(trialEndsAt, "MMM dd, yyyy"),
billingUrl: `${WEBSITE_URL}/dashboard/settings/billing`,
}),
);
await sendEmail({
email: owner.email,
subject:
daysRemaining === 1
? "Your Dokploy trial ends tomorrow"
: `Your Dokploy trial ends in ${daysRemaining} days`,
text: htmlContent,
});
return true;
};
-1
View File
@@ -65,7 +65,6 @@
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.5.1",
"hostinger-api-sdk": "^0.0.17",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.18",
-1
View File
@@ -17,7 +17,6 @@ export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./managed-server";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
@@ -1,76 +0,0 @@
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { server } from "./server";
import { sshKeys } from "./ssh-key";
export const managedServerStatus = pgEnum("managedServerStatus", [
"pending",
"provisioning",
"configuring",
"ready",
"error",
"terminating",
"terminated",
]);
export const managedServer = pgTable("managed_server", {
managedServerId: text("managedServerId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "set null",
}),
/** UpCloud plan name, e.g. "2xCPU-4GB" */
plan: text("plan").notNull(),
status: managedServerStatus("status").notNull().default("pending"),
/** UpCloud server UUID */
providerVmId: text("providerVmId"),
/** UpCloud zone ID, e.g. "fi-hel1" */
zone: text("zone").notNull(),
/** SSH key used to access this server */
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
onDelete: "set null",
}),
ipAddress: text("ipAddress"),
hostname: text("hostname"),
stripeSubscriptionId: text("stripeSubscriptionId"),
stripePriceId: text("stripePriceId"),
errorMessage: text("errorMessage"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text("updatedAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export const managedServerRelations = relations(managedServer, ({ one }) => ({
organization: one(organization, {
fields: [managedServer.organizationId],
references: [organization.id],
}),
server: one(server, {
fields: [managedServer.serverId],
references: [server.serverId],
}),
}));
export const apiCreateManagedServer = z.object({
plan: z.string().min(1),
zone: z.string().min(1),
});
export const apiFindOneManagedServer = z.object({
managedServerId: z.string().min(1),
});
export const apiDeleteManagedServer = z.object({
managedServerId: z.string().min(1),
});
@@ -1,141 +0,0 @@
import {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { emailTailwindConfig } from "../tailwind-config";
export type TemplateProps = {
userName: string;
planName: string;
daysRemaining: number;
endsOn: string;
billingUrl: string;
};
export const TrialExpiringEmail = ({
userName = "User",
planName = "Hobby",
daysRemaining = 3,
endsOn = "2024-01-01",
billingUrl = "https://app.dokploy.com/dashboard/settings/billing",
}: TemplateProps) => {
const dayLabel = daysRemaining === 1 ? "day" : "days";
const previewText =
daysRemaining === 1
? "Your Dokploy trial ends tomorrow"
: `Your Dokploy trial ends in ${daysRemaining} ${dayLabel}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={emailTailwindConfig}>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
{daysRemaining === 1
? "Your trial ends tomorrow"
: `Your trial ends in ${daysRemaining} ${dayLabel}`}
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, add a payment method to keep your servers and
applications running once the trial ends.
</Text>
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
<Row className="bg-[#fafafa]">
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Plan
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{planName}
</Text>
</Column>
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Trial ends
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{endsOn}
</Text>
</Column>
</Row>
</Section>
<Section className="bg-[#fefce8] border border-solid border-[#fef08a] rounded-lg px-[20px] py-[16px] mb-[24px]">
<Text className="text-[#854d0e] text-[13px] leading-[20px] m-0">
Without a payment method your subscription is cancelled when
the trial ends and your servers are deactivated.
</Text>
</Section>
<Section className="text-center mb-[24px]">
<Button
href={billingUrl}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Add Payment Method
</Button>
</Section>
<Hr className="border-[#e4e4e7] my-[24px]" />
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
You are billed only when the trial ends. You can cancel at any
time before then.
</Text>
</Section>
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This is an automated email from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you have any questions about your billing, please contact
our{" "}
<Link
href="https://discord.gg/2tBnJ3jDJc"
className="text-[#71717a] underline"
>
support team
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default TrialExpiringEmail;
-1
View File
@@ -50,7 +50,6 @@ export * from "./services/registry";
export * from "./services/rollbacks";
export * from "./services/schedule";
export * from "./services/security";
export * from "./services/managed-server";
export * from "./services/server";
export * from "./services/server-health";
export * from "./services/settings";
@@ -1,54 +0,0 @@
import { db } from "@dokploy/server/db";
import { managedServer } from "@dokploy/server/db/schema/managed-server";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
export type ManagedServer = typeof managedServer.$inferSelect;
export const createManagedServer = async (
input: typeof managedServer.$inferInsert,
) => {
const record = await db
.insert(managedServer)
.values(input)
.returning()
.then((r) => r[0]);
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
return record;
};
export const findManagedServerById = async (managedServerId: string) => {
const record = await db.query.managedServer.findFirst({
where: eq(managedServer.managedServerId, managedServerId),
with: { server: true },
});
if (!record)
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
return record;
};
export const findManagedServersByOrg = async (organizationId: string) => {
return db.query.managedServer.findMany({
where: eq(managedServer.organizationId, organizationId),
with: { server: true },
orderBy: (t, { desc }) => [desc(t.createdAt)],
});
};
export const updateManagedServer = async (
managedServerId: string,
data: Partial<typeof managedServer.$inferInsert>,
) => {
return db
.update(managedServer)
.set({ ...data, updatedAt: new Date().toISOString() })
.where(eq(managedServer.managedServerId, managedServerId))
.returning()
.then((r) => r[0]);
};
export const deleteManagedServer = async (managedServerId: string) => {
return db
.delete(managedServer)
.where(eq(managedServer.managedServerId, managedServerId));
};
@@ -1,5 +1,6 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { organization } from "@dokploy/server/db/schema";
import { organization, user } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
export const getSSOProviders = async () => {
@@ -44,3 +45,14 @@ export const getOrganizationOwnerId = async (organizationId: string) => {
if (!org) return null;
return org.ownerId;
};
// better-auth won't link SSO to an unverified user; self-hosted owners never verify
export const markOrganizationOwnerVerified = async (organizationId: string) => {
if (IS_CLOUD) return;
const ownerId = await getOrganizationOwnerId(organizationId);
if (!ownerId) return;
await db
.update(user)
.set({ emailVerified: true })
.where(eq(user.id, ownerId));
};
+1 -1
View File
@@ -11,7 +11,7 @@ import { eq } from "drizzle-orm";
import type { z } from "zod";
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
return db.transaction(async (tx) => {
await db.transaction(async (tx) => {
const sshKey = await tx
.insert(sshKeys)
.values(input)
-205
View File
@@ -1,205 +0,0 @@
import {
BillingCatalogApi,
BillingSubscriptionsApi,
Configuration,
VPSDataCentersApi,
VPSVirtualMachineApi,
} from "hostinger-api-sdk";
export type {
BillingV1CatalogCatalogItemResource as HostingerCatalogItem,
VPSV1DataCenterDataCenterResource as HostingerDataCenter,
VPSV1VirtualMachinePurchaseRequest as HostingerPurchaseRequest,
VPSV1VirtualMachineVirtualMachineResource as HostingerVM,
} from "hostinger-api-sdk";
// Correct base URL — api.hostinger.com returns 530, developers.hostinger.com is the real gateway
const HOSTINGER_BASE_PATH = "https://developers.hostinger.com";
function getConfig() {
const apiKey = process.env.HOSTINGER_API_KEY;
if (!apiKey) throw new Error("HOSTINGER_API_KEY is not set");
return new Configuration({
basePath: HOSTINGER_BASE_PATH,
accessToken: apiKey,
});
}
function getVmApi() {
return new VPSVirtualMachineApi(getConfig());
}
export async function getHostingerDataCenters() {
try {
const api = new VPSDataCentersApi(getConfig());
const res = await api.getDataCenterListV1();
return res.data;
} catch (error) {
console.log(error);
}
}
export async function getHostingerVpsCatalog() {
const api = new BillingCatalogApi(getConfig());
const res = await api.getCatalogItemListV1("VPS");
return res.data;
}
const FAKE_VM_ID = 999999;
export async function purchaseHostingerVps(
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
): Promise<import("hostinger-api-sdk").BillingV1OrderVirtualMachineOrderResource> {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log("[hostinger dry-run] purchaseHostingerVps called, skipping real purchase");
return {
virtual_machine: {
id: FAKE_VM_ID,
hostname: body.setup.hostname,
state: "initial",
ipv4: [],
subscription_id: "dry-run-sub-123",
},
} as never;
}
const api = getVmApi();
const res = await api.purchaseNewVirtualMachineV1(body);
return res.data;
}
export async function getHostingerVm(vmId: number) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] getHostingerVm(${vmId})`);
return {
id: vmId,
hostname: "dry-run-vm",
state: "running",
ipv4: [{ address: "1.2.3.4" }],
subscription_id: "dry-run-sub-123",
} as never;
}
const api = getVmApi();
const res = await api.getVirtualMachineDetailsV1(vmId);
return res.data;
}
export async function stopHostingerVm(vmId: number) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] stopHostingerVm(${vmId})`);
return;
}
const api = getVmApi();
await api.stopVirtualMachineV1(vmId);
}
export async function cancelHostingerSubscription(subscriptionId: string) {
if (process.env.HOSTINGER_DRY_RUN === "true") {
console.log(`[hostinger dry-run] cancelHostingerSubscription(${subscriptionId})`);
return;
}
const api = new BillingSubscriptionsApi(getConfig());
await api.cancelSubscriptionV1(subscriptionId, {
reason_code: "other",
cancel_option: "immediately",
});
}
/** Ubuntu 22.04 LTS template ID on Hostinger */
export const UBUNTU_22_TEMPLATE_ID = 1009;
/**
* Markup multiplier applied to Hostinger's catalog price to get Dokploy's user price.
* Hostinger KVM2 = ~$24.49/mo → Dokploy charges $45/mo (~84% markup).
*/
const MARKUP = 1.84;
export interface ManagedServerPlan {
id: string;
name: string;
hostingerItemIdMonthly: string;
hostingerItemIdAnnual: string;
cpus: number;
memoryMb: number;
diskMb: number;
bandwidthMb: number;
/** Price in cents Hostinger charges us monthly */
hostingerPriceCentsMonthly: number;
/** Price in cents we charge the user monthly */
dokployPriceCentsMonthly: number;
/** Price in cents we charge the user annually */
dokployPriceCentsAnnual: number;
}
/** KVM plan IDs offered through Dokploy (excludes Game Panel plans) */
const OFFERED_PLAN_IDS = [
"hostingercom-vps-kvm1",
"hostingercom-vps-kvm2",
"hostingercom-vps-kvm4",
"hostingercom-vps-kvm8",
];
/**
* Fetches live VPS plans from Hostinger catalog and applies Dokploy markup.
* Only returns standard KVM plans (not Game Panel variants).
*/
export async function getManagedServerPlans(): Promise<ManagedServerPlan[]> {
const catalog = await getHostingerVpsCatalog();
const plans: ManagedServerPlan[] = [];
for (const item of catalog) {
if (!OFFERED_PLAN_IDS.includes(item.id ?? "")) continue;
const meta = item.metadata as Record<string, string> | null;
const cpus = Number(meta?.cpus ?? 0);
const memoryMb = Number(meta?.memory ?? 0);
const diskMb = Number(meta?.disk_space ?? 0);
const bandwidthMb = Number(meta?.bandwidth ?? 0);
const monthlyPrice = item.prices?.find(
(p) => p.period === 1 && p.period_unit === "month",
);
const annualPrice = item.prices?.find(
(p) => p.period === 1 && p.period_unit === "year",
);
if (!monthlyPrice) continue;
const hostingerMonthly = monthlyPrice.price ?? 0;
const hostingerAnnual = annualPrice?.price ?? hostingerMonthly * 12;
// Apply markup and round to nearest $0.50 (50 cents)
const dokployMonthly = Math.ceil((hostingerMonthly * MARKUP) / 50) * 50;
const dokployAnnual = Math.ceil((hostingerAnnual * MARKUP) / 50) * 50;
// Derive hostinger item IDs for monthly and annual billing
const hostingerItemIdMonthly = monthlyPrice.id ?? `${item.id}-usd-1m`;
const hostingerItemIdAnnual = annualPrice?.id ?? `${item.id}-usd-1y`;
// Map hostinger plan names to friendly names
const friendlyNames: Record<string, string> = {
"hostingercom-vps-kvm1": "Starter",
"hostingercom-vps-kvm2": "Basic",
"hostingercom-vps-kvm4": "Growth",
"hostingercom-vps-kvm8": "Scale",
};
plans.push({
id: item.id ?? "",
name: friendlyNames[item.id ?? ""] ?? item.name ?? item.id ?? "",
hostingerItemIdMonthly,
hostingerItemIdAnnual,
cpus,
memoryMb,
diskMb,
bandwidthMb,
hostingerPriceCentsMonthly: hostingerMonthly,
dokployPriceCentsMonthly: dokployMonthly,
dokployPriceCentsAnnual: dokployAnnual,
});
}
return plans;
}
export type ManagedServerPlanId = string;
-220
View File
@@ -1,220 +0,0 @@
import { defaultCommand } from "../setup/server-setup";
const UPCLOUD_BASE = "https://api.upcloud.com/1.3";
const UBUNTU_22_TEMPLATE_UUID = "01000000-0000-4000-8000-000030220200";
export { UBUNTU_22_TEMPLATE_UUID };
export interface DokployPlan {
id: string;
name: string;
priceCents: number;
upcloudPlan: string;
cpus: number;
memoryGb: number;
storageGb: number;
}
export const DOKPLOY_PLANS: DokployPlan[] = [
{
id: "hobby",
name: "Hobby",
priceCents: 2499,
upcloudPlan: "1xCPU-2GB",
cpus: 1,
memoryGb: 2,
storageGb: 50,
},
{
id: "starter",
name: "Starter",
priceCents: 4900,
upcloudPlan: "2xCPU-4GB",
cpus: 2,
memoryGb: 4,
storageGb: 80,
},
{
id: "pro",
name: "Pro",
priceCents: 9900,
upcloudPlan: "4xCPU-8GB",
cpus: 4,
memoryGb: 8,
storageGb: 160,
},
{
id: "business",
name: "Business",
priceCents: 19900,
upcloudPlan: "HIMEM-4xCPU-16GB",
cpus: 4,
memoryGb: 16,
storageGb: 320,
},
];
function getHeaders() {
const token = process.env.UPCLOUD_TOKEN;
if (!token) throw new Error("UPCLOUD_TOKEN is not set");
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
};
}
async function upcloudFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(`${UPCLOUD_BASE}${path}`, {
...options,
headers: { ...getHeaders(), ...(options.headers ?? {}) },
});
if (!res.ok) {
let body: unknown;
try {
body = await res.json();
} catch {
body = await res.text();
}
const err = new Error(
`UpCloud API error ${res.status}: ${JSON.stringify(body)}`,
) as Error & { response: { status: number; data: unknown } };
err.response = { status: res.status, data: body };
throw err;
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export interface UpCloudZone {
id: string;
description: string;
public: "yes" | "no";
}
export type UpCloudZoneContinent = "Europe" | "Americas" | "Asia-Pacific";
export interface UpCloudZoneWithContinent extends UpCloudZone {
continent: UpCloudZoneContinent;
}
const ZONE_CONTINENT: Record<string, UpCloudZoneContinent> = {
"fi-hel1": "Europe",
"fi-hel2": "Europe",
"de-fra1": "Europe",
"nl-ams1": "Europe",
"uk-lon1": "Europe",
"se-sto1": "Europe",
"dk-cph1": "Europe",
"no-svg1": "Europe",
"pl-waw1": "Europe",
"es-mad1": "Europe",
"us-nyc1": "Americas",
"us-chi1": "Americas",
"us-sjo1": "Americas",
"sg-sin1": "Asia-Pacific",
"au-syd1": "Asia-Pacific",
};
export async function getUpCloudZones(): Promise<UpCloudZoneWithContinent[]> {
const data = await upcloudFetch<{ zones: { zone: UpCloudZone[] } }>("/zone");
return data.zones.zone
.filter((z) => z.public === "yes" && z.id in ZONE_CONTINENT)
.map((z) => ({ ...z, continent: ZONE_CONTINENT[z.id]! }))
.sort(
(a, b) =>
a.continent.localeCompare(b.continent) ||
a.description.localeCompare(b.description),
);
}
export interface UpCloudServer {
uuid: string;
hostname: string;
title: string;
state: "started" | "stopped" | "maintenance" | "error";
zone: string;
plan: string;
core_number: string;
memory_amount: string;
ip_addresses: {
ip_address: Array<{
access: "public" | "utility" | "private";
address: string;
family: "IPv4" | "IPv6";
}>;
};
}
export async function createUpCloudServer(params: {
hostname: string;
upcloudPlan: string;
zone: string;
sshKey: string;
}): Promise<UpCloudServer> {
const userDataScript = `#!/bin/bash\n${defaultCommand(false)}`;
const body = {
server: {
zone: params.zone,
title: params.hostname,
hostname: params.hostname,
plan: params.upcloudPlan,
firewall: "on",
metadata: "yes",
user_data: userDataScript,
login_user: {
username: "root",
ssh_keys: {
ssh_key: [params.sshKey],
},
create_password: "no",
},
storage_devices: {
storage_device: [
{
action: "clone",
storage: UBUNTU_22_TEMPLATE_UUID,
title: `${params.hostname}-disk`,
tier: "maxiops",
},
],
},
},
};
const data = await upcloudFetch<{ server: UpCloudServer }>("/server", {
method: "POST",
body: JSON.stringify(body),
});
return data.server;
}
export async function getUpCloudServer(uuid: string): Promise<UpCloudServer> {
const data = await upcloudFetch<{ server: UpCloudServer }>(`/server/${uuid}`);
return data.server;
}
export async function stopUpCloudServer(uuid: string): Promise<void> {
await upcloudFetch(`/server/${uuid}/stop`, {
method: "POST",
body: JSON.stringify({ stop_server: { stop_type: "hard", timeout: "60" } }),
});
}
export async function deleteUpCloudServer(uuid: string): Promise<void> {
await upcloudFetch(`/server/${uuid}?storages=1&backups=delete`, {
method: "DELETE",
});
}
export function getPublicIPv4(server: UpCloudServer): string | undefined {
return server.ip_addresses.ip_address.find(
(ip) => ip.access === "public" && ip.family === "IPv4",
)?.address;
}
-13
View File
@@ -680,9 +680,6 @@ importers:
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(zod@4.3.6)
hostinger-api-sdk:
specifier: ^0.0.17
version: 0.0.17
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -6310,9 +6307,6 @@ packages:
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
engines: {node: '>=16.9.0'}
hostinger-api-sdk@0.0.17:
resolution: {integrity: sha512-PGIS2P4bwwvztlUHTdXYia7sAJsmDd9qsSE2tr8wDMAAjYow0J979w4dHcuHfC4ovo8nZoj3btqTDJtIIeSPYw==}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -15397,13 +15391,6 @@ snapshots:
hono@4.12.2: {}
hostinger-api-sdk@0.0.17:
dependencies:
axios: 1.18.0
transitivePeerDependencies:
- debug
- supports-color
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0