fix(ui): improve select component behavior and styling across various providers

- Added a check to prevent empty values from being processed in the onValueChange handler for Bitbucket, Gitea, GitHub, and GitLab providers.
- Removed unnecessary defaultValue prop from Select components to streamline the code.
- Updated button styles to remove background color for better consistency across the UI.
- Enhanced volume backup selection to display a message when no volumes are found.

This update enhances user experience by ensuring that empty selections are handled gracefully and improves the overall visual consistency of the UI components.
This commit is contained in:
Mauricio Siu
2026-07-06 03:27:52 -06:00
parent 2440e8f803
commit 8db1250487
29 changed files with 451 additions and 124 deletions

View File

@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Github Account" />
<SelectValue placeholder="Select a Github Account">
{
githubProviders?.find(
(githubProvider) =>
githubProvider.githubId === field.value,
)?.gitProvider.name
}
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -507,11 +507,17 @@ export const HandleVolumeBackups = ({
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts?.map((mount) => (
<SelectItem key={mount.Name} value={mount.Name || ""}>
{mount.Name}
{mounts && mounts.length > 0 ? (
mounts.map((mount) => (
<SelectItem key={mount.Name} value={mount.Name || ""}>
{mount.Name}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
No volumes found
</SelectItem>
))}
)}
</SelectContent>
</Select>
<FormDescription>

View File

@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
)?.name ?? field.value.repo)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
? (branches?.find(
(branch) => branch.name === field.value,
)?.name
)?.name ?? field.value)
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between bg-input!",
" w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -409,7 +409,7 @@ export const HandleBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -345,7 +345,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between bg-input!",
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>

View File

@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
.catch((error) => {
console.error(error);
toast.error(
`Failed to ${organizationId ? "update" : "create"} organization`,
error?.message ??
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};

View File

@@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
import { XIcon } from "lucide-react";
function Dialog({
@@ -49,6 +50,8 @@ function DialogContent({
className,
children,
showCloseButton = true,
onPointerDownOutside,
onEscapeKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
@@ -62,6 +65,20 @@ function DialogContent({
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
onPointerDownOutside={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onPointerDownOutside?.(event);
}}
onEscapeKeyDown={(event) => {
if (wasNestedPopupJustClosed()) {
event.preventDefault();
return;
}
onEscapeKeyDown?.(event);
}}
{...props}
>
{children}

View File

@@ -2,12 +2,25 @@ import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
function DropdownMenu({
onOpenChange,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
return (
<DropdownMenuPrimitive.Root
data-slot="dropdown-menu"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
}
function DropdownMenuPortal({

View File

@@ -0,0 +1,9 @@
let lastNestedPopupCloseAt = 0;
export function markNestedPopupClosed() {
lastNestedPopupCloseAt = performance.now();
}
export function wasNestedPopupJustClosed() {
return performance.now() - lastNestedPopupCloseAt < 100;
}

View File

@@ -4,11 +4,24 @@ import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
function Popover({
onOpenChange,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
return (
<PopoverPrimitive.Root
data-slot="popover"
onOpenChange={(open) => {
if (!open) {
markNestedPopupClosed();
}
onOpenChange?.(open);
}}
{...props}
/>
);
}
function PopoverTrigger({

View File

@@ -58,7 +58,7 @@ function SelectTrigger({
function SelectContent({
className,
children,
position = "item-aligned",
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {

View File

@@ -1878,7 +1878,7 @@ export async function getServerSideProps(
// Try to find default, otherwise use first accessible
const targetEnv =
accessibleEnvironments.find((env) => env.isDefault) ||
accessibleEnvironments[0];
accessibleEnvironments[0]!;
return {
redirect: {

View File

@@ -1,6 +1,7 @@
import {
createBackup,
findBackupById,
findBackupsByDbId,
findComposeByBackupId,
findComposeById,
findLibsqlByBackupId,
@@ -55,6 +56,7 @@ import {
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
import {
apiCreateBackup,
apiFindOneBackup,
@@ -94,6 +96,22 @@ export const backupRouter = createTRPCRouter({
});
}
if (IS_CLOUD) {
const dbType = (
["postgres", "mysql", "mariadb", "mongo", "libsql"] as const
).find((type) => input[`${type}Id`]);
if (dbType) {
const existingBackups = await findBackupsByDbId(
input[`${dbType}Id`]!,
dbType,
);
await assertDatabaseBackupLimit(
ctx.session.activeOrganizationId,
existingBackups.length,
);
}
}
const newBackup = await createBackup(input);
const backup = await findBackupById(newBackup.backupId);

View File

@@ -2,8 +2,10 @@ import {
createEnvironment,
deleteEnvironment,
duplicateEnvironment,
filterEnvironmentServices,
findEnvironmentById,
findEnvironmentsByProjectId,
IS_CLOUD,
updateEnvironmentById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -20,6 +22,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
import {
apiCreateEnvironment,
apiDuplicateEnvironment,
@@ -30,43 +33,18 @@ import {
projects,
} from "@/server/db/schema";
const filterEnvironmentServices = (
environment: any,
accessedServices: string[],
) => ({
...environment,
applications: environment.applications.filter((app: any) =>
accessedServices.includes(app.applicationId),
),
compose: environment.compose.filter((comp: any) =>
accessedServices.includes(comp.composeId),
),
libsql: environment.libsql.filter((db: any) =>
accessedServices.includes(db.libsqlId),
),
mariadb: environment.mariadb.filter((db: any) =>
accessedServices.includes(db.mariadbId),
),
mongo: environment.mongo.filter((db: any) =>
accessedServices.includes(db.mongoId),
),
mysql: environment.mysql.filter((db: any) =>
accessedServices.includes(db.mysqlId),
),
postgres: environment.postgres.filter((db: any) =>
accessedServices.includes(db.postgresId),
),
redis: environment.redis.filter((db: any) =>
accessedServices.includes(db.redisId),
),
});
export const environmentRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => {
try {
await checkEnvironmentCreationPermission(ctx, input.projectId);
if (IS_CLOUD) {
await assertEnvironmentLimit(
ctx.session.activeOrganizationId,
input.projectId,
);
}
if (input.name === "production") {
throw new TRPCError({

View File

@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import {
assertMemberLimit,
assertOrganizationLimit,
} from "@/server/api/utils/plan-limits";
import {
invitation,
member,
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
message: "Only the organization owner can create an organization",
});
}
if (IS_CLOUD) {
await assertOrganizationLimit(ctx.user.id);
}
const result = await db
.insert(organization)
.values({
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
const orgId = ctx.session.activeOrganizationId;
const email = input.email.toLowerCase();
if (IS_CLOUD) {
await assertMemberLimit(orgId);
}
// Check if user is already a member
const existingUser = await db.query.user.findFirst({
where: eq(user.email, email),

View File

@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
import { asc, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
input.applicationId ? "application" : "compose",
serviceId,
);
}
} else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
message: "You don't have access to this server.",
});
}
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
}
}
}
const newSchedule = await createSchedule({

View File

@@ -7,6 +7,7 @@ import {
import { TRPCError } from "@trpc/server";
import Stripe from "stripe";
import { z } from "zod";
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
import {
type BillingTier,
getStripeItems,
@@ -31,44 +32,7 @@ import {
export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return null;
const owner = await findUserById(ctx.user.ownerId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup" as const;
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby" as const;
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy" as const;
}
return null;
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
}),
getProducts: adminProcedure.query(async ({ ctx }) => {

View File

@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
create: protectedProcedure
.input(createVolumeBackupSchema)
.mutation(async ({ input, ctx }) => {
const serviceId =
input.applicationId ||
input.postgresId ||
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.redisId ||
input.libsqlId ||
input.composeId;
const serviceType = (
[
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"libsql",
"compose",
] as const
).find((type) => input[`${type}Id`]);
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volumeBackup: ["create"],
});
}
if (IS_CLOUD && serviceType && serviceId) {
const existingVolumeBackups = await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
});
await assertVolumeBackupLimit(
ctx.session.activeOrganizationId,
existingVolumeBackups.length,
);
}
const newVolumeBackup = await createVolumeBackup(input);
if (newVolumeBackup?.enabled) {

View File

@@ -0,0 +1,130 @@
import { db } from "@dokploy/server/db";
import {
environments,
member,
organization,
schedules,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { getCurrentPlan, getCurrentPlanForUser } from "@/server/utils/billing";
export type PlanLimitResource =
| "organization"
| "member"
| "environment"
| "volumeBackup"
| "databaseBackup"
| "scheduledJob";
const UNLIMITED = Number.POSITIVE_INFINITY;
export const PLAN_LIMITS: Record<
"hobby" | "startup" | "legacy",
Record<PlanLimitResource, number>
> = {
hobby: {
organization: 1,
member: 1,
environment: 2,
volumeBackup: 1,
databaseBackup: 1,
scheduledJob: 1,
},
startup: {
organization: 3,
member: UNLIMITED,
environment: UNLIMITED,
volumeBackup: UNLIMITED,
databaseBackup: UNLIMITED,
scheduledJob: UNLIMITED,
},
legacy: {
organization: UNLIMITED,
member: UNLIMITED,
environment: UNLIMITED,
volumeBackup: UNLIMITED,
databaseBackup: UNLIMITED,
scheduledJob: UNLIMITED,
},
};
const resourceLabels: Record<PlanLimitResource, string> = {
organization: "organizations",
member: "users",
environment: "environments per project",
volumeBackup: "volume backups per application",
databaseBackup: "backups per database",
scheduledJob: "scheduled jobs per service",
};
const assertLimitForPlan = (
plan: "hobby" | "startup" | "legacy" | null,
resource: PlanLimitResource,
currentCount: number,
) => {
const limit = PLAN_LIMITS[plan ?? "legacy"][resource];
if (currentCount >= limit) {
throw new TRPCError({
code: "FORBIDDEN",
message: `You've reached your plan's limit of ${limit} ${resourceLabels[resource]}. Upgrade your plan to add more.`,
});
}
};
export const assertOrganizationLimit = async (userId: string) => {
const plan = await getCurrentPlanForUser(userId);
const organizations = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),
});
assertLimitForPlan(plan, "organization", organizations.length);
};
export const assertMemberLimit = async (organizationId: string) => {
const plan = await getCurrentPlan(organizationId);
const members = await db.query.member.findMany({
where: eq(member.organizationId, organizationId),
});
assertLimitForPlan(plan, "member", members.length);
};
export const assertEnvironmentLimit = async (
organizationId: string,
projectId: string,
) => {
const plan = await getCurrentPlan(organizationId);
const envs = await db.query.environments.findMany({
where: eq(environments.projectId, projectId),
});
assertLimitForPlan(plan, "environment", envs.length);
};
export const assertVolumeBackupLimit = async (
organizationId: string,
currentCount: number,
) => {
const plan = await getCurrentPlan(organizationId);
assertLimitForPlan(plan, "volumeBackup", currentCount);
};
export const assertDatabaseBackupLimit = async (
organizationId: string,
currentCount: number,
) => {
const plan = await getCurrentPlan(organizationId);
assertLimitForPlan(plan, "databaseBackup", currentCount);
};
export const assertScheduledJobLimit = async (
organizationId: string,
scheduleType: "application" | "compose" | "server",
serviceId: string,
) => {
const plan = await getCurrentPlan(organizationId);
const column = `${scheduleType}Id` as const;
const rows = await db.query.schedules.findMany({
where: eq(schedules[column], serviceId),
});
assertLimitForPlan(plan, "scheduledJob", rows.length);
};

View File

@@ -0,0 +1,69 @@
import { findUserById, IS_CLOUD } from "@dokploy/server";
import { getOrganizationOwnerId } from "@dokploy/server/services/proprietary/sso";
import Stripe from "stripe";
import {
HOBBY_PRICE_ANNUAL_ID,
HOBBY_PRICE_MONTHLY_ID,
LEGACY_PRICE_IDS,
STARTUP_BASE_PRICE_ANNUAL_ID,
STARTUP_BASE_PRICE_MONTHLY_ID,
} from "@/server/utils/stripe";
export type BillingPlan = "legacy" | "hobby" | "startup";
export const getCurrentPlanForUser = async (
userId: string,
): Promise<BillingPlan | null> => {
if (!IS_CLOUD) return null;
const owner = await findUserById(userId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup";
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby";
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy";
}
return null;
};
export const getCurrentPlan = async (
organizationId: string,
): Promise<BillingPlan | null> => {
if (!IS_CLOUD) return null;
const ownerId = await getOrganizationOwnerId(organizationId);
if (!ownerId) return null;
return getCurrentPlanForUser(ownerId);
};