mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-04 11:12:16 +00:00
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:
+5
-3
@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Bitbucket Account</FormLabel>
|
<FormLabel>Bitbucket Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+5
-3
@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Gitea Account</FormLabel>
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+16
-6
@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Github Account</FormLabel>
|
<FormLabel>Github Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a Github Account" />
|
<SelectValue placeholder="Select a Github Account">
|
||||||
|
{
|
||||||
|
githubProviders?.find(
|
||||||
|
(githubProvider) =>
|
||||||
|
githubProvider.githubId === field.value,
|
||||||
|
)?.gitProvider.name
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(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" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? (branches?.find(
|
||||||
(branch) => branch.name === field.value,
|
(branch) => branch.name === field.value,
|
||||||
)?.name
|
)?.name ?? field.value)
|
||||||
: "Select branch"}
|
: "Select branch"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+5
-3
@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Gitlab Account</FormLabel>
|
<FormLabel>Gitlab Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+10
-4
@@ -507,11 +507,17 @@ export const HandleVolumeBackups = ({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{mounts?.map((mount) => (
|
{mounts && mounts.length > 0 ? (
|
||||||
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
mounts.map((mount) => (
|
||||||
{mount.Name}
|
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
||||||
|
{mount.Name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
No volumes found
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
+2
-2
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+5
-3
@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Bitbucket Account</FormLabel>
|
<FormLabel>Bitbucket Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+5
-3
@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Gitea Account</FormLabel>
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+9
-7
@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules ?? false,
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data?.composeId, form]);
|
}, [form.reset, data]);
|
||||||
|
|
||||||
const onSubmit = async (data: GithubProvider) => {
|
const onSubmit = async (data: GithubProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Github Account</FormLabel>
|
<FormLabel>Github Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(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" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? (branches?.find(
|
||||||
(branch) => branch.name === field.value,
|
(branch) => branch.name === field.value,
|
||||||
)?.name
|
)?.name ?? field.value)
|
||||||
: "Select branch"}
|
: "Select branch"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+5
-3
@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Gitlab Account</FormLabel>
|
<FormLabel>Gitlab Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ export const HandleBackup = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export const RestoreBackup = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(
|
toast.error(
|
||||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
error?.message ??
|
||||||
|
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
@@ -49,6 +50,8 @@ function DialogContent({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
|
onPointerDownOutside,
|
||||||
|
onEscapeKeyDown,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean;
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
if (wasNestedPopupJustClosed()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPointerDownOutside?.(event);
|
||||||
|
}}
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (wasNestedPopupJustClosed()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onEscapeKeyDown?.(event);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ import * as React from "react";
|
|||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
|
onOpenChange,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: 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({
|
function DropdownMenuPortal({
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
let lastNestedPopupCloseAt = 0;
|
||||||
|
|
||||||
|
export function markNestedPopupClosed() {
|
||||||
|
lastNestedPopupCloseAt = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasNestedPopupJustClosed() {
|
||||||
|
return performance.now() - lastNestedPopupCloseAt < 100;
|
||||||
|
}
|
||||||
@@ -4,11 +4,24 @@ import * as React from "react";
|
|||||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
|
onOpenChange,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
}: 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({
|
function PopoverTrigger({
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function SelectTrigger({
|
|||||||
function SelectContent({
|
function SelectContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
position = "item-aligned",
|
position = "popper",
|
||||||
align = "center",
|
align = "center",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
|||||||
@@ -1878,7 +1878,7 @@ export async function getServerSideProps(
|
|||||||
// Try to find default, otherwise use first accessible
|
// Try to find default, otherwise use first accessible
|
||||||
const targetEnv =
|
const targetEnv =
|
||||||
accessibleEnvironments.find((env) => env.isDefault) ||
|
accessibleEnvironments.find((env) => env.isDefault) ||
|
||||||
accessibleEnvironments[0];
|
accessibleEnvironments[0]!;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createBackup,
|
createBackup,
|
||||||
findBackupById,
|
findBackupById,
|
||||||
|
findBackupsByDbId,
|
||||||
findComposeByBackupId,
|
findComposeByBackupId,
|
||||||
findComposeById,
|
findComposeById,
|
||||||
findLibsqlByBackupId,
|
findLibsqlByBackupId,
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
withPermission,
|
withPermission,
|
||||||
} from "@/server/api/trpc";
|
} from "@/server/api/trpc";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
|
||||||
import {
|
import {
|
||||||
apiCreateBackup,
|
apiCreateBackup,
|
||||||
apiFindOneBackup,
|
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 newBackup = await createBackup(input);
|
||||||
const backup = await findBackupById(newBackup.backupId);
|
const backup = await findBackupById(newBackup.backupId);
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import {
|
|||||||
createEnvironment,
|
createEnvironment,
|
||||||
deleteEnvironment,
|
deleteEnvironment,
|
||||||
duplicateEnvironment,
|
duplicateEnvironment,
|
||||||
|
filterEnvironmentServices,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findEnvironmentsByProjectId,
|
findEnvironmentsByProjectId,
|
||||||
|
IS_CLOUD,
|
||||||
updateEnvironmentById,
|
updateEnvironmentById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
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 { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
|
||||||
import {
|
import {
|
||||||
apiCreateEnvironment,
|
apiCreateEnvironment,
|
||||||
apiDuplicateEnvironment,
|
apiDuplicateEnvironment,
|
||||||
@@ -30,43 +33,18 @@ import {
|
|||||||
projects,
|
projects,
|
||||||
} from "@/server/db/schema";
|
} 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({
|
export const environmentRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateEnvironment)
|
.input(apiCreateEnvironment)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
await checkEnvironmentCreationPermission(ctx, input.projectId);
|
await checkEnvironmentCreationPermission(ctx, input.projectId);
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertEnvironmentLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
input.projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (input.name === "production") {
|
if (input.name === "production") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import {
|
||||||
|
assertMemberLimit,
|
||||||
|
assertOrganizationLimit,
|
||||||
|
} from "@/server/api/utils/plan-limits";
|
||||||
import {
|
import {
|
||||||
invitation,
|
invitation,
|
||||||
member,
|
member,
|
||||||
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
message: "Only the organization owner can create an organization",
|
message: "Only the organization owner can create an organization",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertOrganizationLimit(ctx.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.insert(organization)
|
.insert(organization)
|
||||||
.values({
|
.values({
|
||||||
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
const orgId = ctx.session.activeOrganizationId;
|
const orgId = ctx.session.activeOrganizationId;
|
||||||
const email = input.email.toLowerCase();
|
const email = input.email.toLowerCase();
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertMemberLimit(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is already a member
|
// Check if user is already a member
|
||||||
const existingUser = await db.query.user.findFirst({
|
const existingUser = await db.query.user.findFirst({
|
||||||
where: eq(user.email, email),
|
where: eq(user.email, email),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { asc, desc, eq } from "drizzle-orm";
|
import { asc, desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
|
||||||
import { removeJob, schedule } from "@/server/utils/backup";
|
import { removeJob, schedule } from "@/server/utils/backup";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["create"],
|
schedule: ["create"],
|
||||||
});
|
});
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertScheduledJobLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
input.applicationId ? "application" : "compose",
|
||||||
|
serviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
|
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
message: "You don't have access to this server.",
|
message: "You don't have access to this server.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertScheduledJobLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
"server",
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newSchedule = await createSchedule({
|
const newSchedule = await createSchedule({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
|
||||||
import {
|
import {
|
||||||
type BillingTier,
|
type BillingTier,
|
||||||
getStripeItems,
|
getStripeItems,
|
||||||
@@ -31,44 +32,7 @@ import {
|
|||||||
export const stripeRouter = createTRPCRouter({
|
export const stripeRouter = createTRPCRouter({
|
||||||
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
||||||
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
||||||
if (!IS_CLOUD) return null;
|
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
||||||
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;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
|
||||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||||
|
|
||||||
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createVolumeBackupSchema)
|
.input(createVolumeBackupSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const serviceId =
|
const serviceType = (
|
||||||
input.applicationId ||
|
[
|
||||||
input.postgresId ||
|
"application",
|
||||||
input.mysqlId ||
|
"postgres",
|
||||||
input.mariadbId ||
|
"mysql",
|
||||||
input.mongoId ||
|
"mariadb",
|
||||||
input.redisId ||
|
"mongo",
|
||||||
input.libsqlId ||
|
"redis",
|
||||||
input.composeId;
|
"libsql",
|
||||||
|
"compose",
|
||||||
|
] as const
|
||||||
|
).find((type) => input[`${type}Id`]);
|
||||||
|
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
volumeBackup: ["create"],
|
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);
|
const newVolumeBackup = await createVolumeBackup(input);
|
||||||
|
|
||||||
if (newVolumeBackup?.enabled) {
|
if (newVolumeBackup?.enabled) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -308,6 +308,48 @@ export const duplicateEnvironment = async (
|
|||||||
return newEnvironment;
|
return newEnvironment;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EnvironmentWithServices {
|
||||||
|
applications: { applicationId: string }[];
|
||||||
|
compose: { composeId: string }[];
|
||||||
|
libsql: { libsqlId: string }[];
|
||||||
|
mariadb: { mariadbId: string }[];
|
||||||
|
mongo: { mongoId: string }[];
|
||||||
|
mysql: { mysqlId: string }[];
|
||||||
|
postgres: { postgresId: string }[];
|
||||||
|
redis: { redisId: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
|
||||||
|
environment: T,
|
||||||
|
accessedServices: string[],
|
||||||
|
): T => ({
|
||||||
|
...environment,
|
||||||
|
applications: environment.applications.filter((app) =>
|
||||||
|
accessedServices.includes(app.applicationId),
|
||||||
|
),
|
||||||
|
compose: environment.compose.filter((comp) =>
|
||||||
|
accessedServices.includes(comp.composeId),
|
||||||
|
),
|
||||||
|
libsql: environment.libsql.filter((db) =>
|
||||||
|
accessedServices.includes(db.libsqlId),
|
||||||
|
),
|
||||||
|
mariadb: environment.mariadb.filter((db) =>
|
||||||
|
accessedServices.includes(db.mariadbId),
|
||||||
|
),
|
||||||
|
mongo: environment.mongo.filter((db) =>
|
||||||
|
accessedServices.includes(db.mongoId),
|
||||||
|
),
|
||||||
|
mysql: environment.mysql.filter((db) =>
|
||||||
|
accessedServices.includes(db.mysqlId),
|
||||||
|
),
|
||||||
|
postgres: environment.postgres.filter((db) =>
|
||||||
|
accessedServices.includes(db.postgresId),
|
||||||
|
),
|
||||||
|
redis: environment.redis.filter((db) =>
|
||||||
|
accessedServices.includes(db.redisId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const createProductionEnvironment = async (projectId: string) => {
|
export const createProductionEnvironment = async (projectId: string) => {
|
||||||
const newEnvironment = await db
|
const newEnvironment = await db
|
||||||
.insert(environments)
|
.insert(environments)
|
||||||
|
|||||||
Reference in New Issue
Block a user