mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso) (#4511)
* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso) - Add `remoteServersOnly` field to webServerSettings: prevents creating services on the local Dokploy VM, forcing all deployments to remote servers. Validated in all 8 service routers (application, compose, postgres, mysql, mongo, redis, mariadb, libsql). - Add `enforceSSO` field to webServerSettings: hides the email/password login form and shows only the SSO button on the login page. - Both settings are enterprise-only (enterpriseProcedure) and self-hosted-only (blocked at the API level when IS_CLOUD=true). - UI toggles added to the SSO settings page under a new "Self-hosted Restrictions" card (hidden in cloud). Login page reads enforceSSO from getServerSideProps to avoid client-side flash. - Migrations: 0167_fresh_goliath.sql, 0168_long_justice.sql * fix: add missing final newlines to migration files * refactor: improve code formatting for better readability in multiple components - Adjusted formatting in `add-application.tsx`, `add-compose.tsx`, and `add-database.tsx` to enhance readability by adding line breaks and consistent indentation. - Updated `toggle-enforce-sso.tsx` to simplify the Switch component's props. - Reformatted imports in `index.tsx` and `sso.tsx` for consistency. - Cleaned up conditional statements in various router files for improved clarity. * fix: add enforceSSO to test mock
This commit is contained in:
@@ -65,6 +65,8 @@ const baseSettings: WebServerSettings = {
|
|||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
|
remoteServersOnly: false,
|
||||||
|
enforceSSO: false,
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ interface Props {
|
|||||||
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: webServerSettings } =
|
||||||
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
@@ -171,7 +174,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
Select a Server{" "}
|
||||||
|
{showLocalOption ? "(Optional)" : ""}
|
||||||
<HelpCircle className="size-4 text-muted-foreground" />
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -191,17 +195,19 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
placeholder={
|
||||||
|
showLocalOption ? "Dokploy" : "Select a Server"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{!isCloud && (
|
{showLocalOption && (
|
||||||
<SelectItem value="dokploy">
|
<SelectItem value="dokploy">
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
<span>Dokploy</span>
|
<span>Dokploy</span>
|
||||||
@@ -225,7 +231,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
Servers (
|
||||||
|
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: webServerSettings } =
|
||||||
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.compose.create.useMutation();
|
api.compose.create.useMutation();
|
||||||
@@ -182,7 +185,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
Select a Server{" "}
|
||||||
|
{showLocalOption ? "(Optional)" : ""}
|
||||||
<HelpCircle className="size-4 text-muted-foreground" />
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -202,17 +206,19 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
placeholder={
|
||||||
|
showLocalOption ? "Dokploy" : "Select a Server"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{!isCloud && (
|
{showLocalOption && (
|
||||||
<SelectItem value="dokploy">
|
<SelectItem value="dokploy">
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
<span>Dokploy</span>
|
<span>Dokploy</span>
|
||||||
@@ -236,7 +242,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
Servers (
|
||||||
|
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: webServerSettings } =
|
||||||
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const libsqlMutation = api.libsql.create.useMutation();
|
const libsqlMutation = api.libsql.create.useMutation();
|
||||||
const mariadbMutation = api.mariadb.create.useMutation();
|
const mariadbMutation = api.mariadb.create.useMutation();
|
||||||
@@ -470,19 +473,20 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
field.value ||
|
||||||
|
(showLocalOption ? "dokploy" : undefined)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={
|
||||||
!isCloud ? "Dokploy" : "Select a Server"
|
showLocalOption ? "Dokploy" : "Select a Server"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{!isCloud && (
|
{showLocalOption && (
|
||||||
<SelectItem value="dokploy">
|
<SelectItem value="dokploy">
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
<span>Dokploy</span>
|
<span>Dokploy</span>
|
||||||
@@ -501,7 +505,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
Servers (
|
||||||
|
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const ToggleEnforceSSO = () => {
|
||||||
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
|
const { mutateAsync } = api.settings.updateEnforceSSO.useMutation();
|
||||||
|
|
||||||
|
const handleToggle = async (checked: boolean) => {
|
||||||
|
try {
|
||||||
|
await mutateAsync({ enforceSSO: checked });
|
||||||
|
await refetch();
|
||||||
|
toast.success("Enforce SSO updated");
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating Enforce SSO");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Switch checked={!!data?.enforceSSO} onCheckedChange={handleToggle} />
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||||
|
Enforce SSO
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</Label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-sm">
|
||||||
|
<p>
|
||||||
|
When enabled, the email/password login form is hidden and users
|
||||||
|
must sign in exclusively through SSO.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const ToggleRemoteServersOnly = () => {
|
||||||
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync } = api.settings.updateRemoteServersOnly.useMutation();
|
||||||
|
|
||||||
|
const handleToggle = async (checked: boolean) => {
|
||||||
|
try {
|
||||||
|
await mutateAsync({ remoteServersOnly: checked });
|
||||||
|
await refetch();
|
||||||
|
toast.success("Remote Servers Only updated");
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating Remote Servers Only");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Switch
|
||||||
|
checked={!!data?.remoteServersOnly}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||||
|
Remote Servers Only
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</Label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-sm">
|
||||||
|
<p>
|
||||||
|
When enabled, all services (applications, databases, compose) must
|
||||||
|
be deployed to a remote server. Deploying directly to the Dokploy
|
||||||
|
host VM is not allowed.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,10 +29,15 @@ type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
|
|||||||
|
|
||||||
interface SignInWithSSOProps {
|
interface SignInWithSSOProps {
|
||||||
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
||||||
children: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
/** When true, SSO is the only option — no fallback to email/password */
|
||||||
|
enforce?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
export function SignInWithSSO({
|
||||||
|
children,
|
||||||
|
enforce = false,
|
||||||
|
}: SignInWithSSOProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const form = useForm<SSOEmailForm>({
|
const form = useForm<SSOEmailForm>({
|
||||||
@@ -72,7 +77,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
|||||||
<LogIn className="mr-2 size-4" />
|
<LogIn className="mr-2 size-4" />
|
||||||
Sign in with SSO
|
Sign in with SSO
|
||||||
</Button>
|
</Button>
|
||||||
{children}
|
{!enforce && children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,13 +118,15 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
{!enforce && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setExpanded(false)}
|
type="button"
|
||||||
className="text-xs text-muted-foreground hover:underline"
|
onClick={() => setExpanded(false)}
|
||||||
>
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
Use email and password instead
|
>
|
||||||
</button>
|
Use email and password instead
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
apps/dokploy/drizzle/0167_fresh_goliath.sql
Normal file
1
apps/dokploy/drizzle/0167_fresh_goliath.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;
|
||||||
1
apps/dokploy/drizzle/0168_long_justice.sql
Normal file
1
apps/dokploy/drizzle/0168_long_justice.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;
|
||||||
8325
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
8325
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8332
apps/dokploy/drizzle/meta/0168_snapshot.json
Normal file
8332
apps/dokploy/drizzle/meta/0168_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1170,6 +1170,20 @@
|
|||||||
"when": 1778303519111,
|
"when": 1778303519111,
|
||||||
"tag": "0166_nosy_slapstick",
|
"tag": "0166_nosy_slapstick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 167,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780122576214,
|
||||||
|
"tag": "0167_fresh_goliath",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 168,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780122833339,
|
||||||
|
"tag": "0168_long_justice",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { validateRequest } from "@dokploy/server";
|
import { IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { ToggleEnforceSSO } from "@/components/dashboard/settings/servers/actions/toggle-enforce-sso";
|
||||||
|
import { ToggleRemoteServersOnly } from "@/components/dashboard/settings/servers/actions/toggle-remote-servers-only";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||||
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
||||||
import { Card } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
|
|
||||||
const Page = () => {
|
interface Props {
|
||||||
|
isCloud: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = ({ isCloud }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
@@ -29,6 +41,33 @@ const Page = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{!isCloud && (
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<EnterpriseFeatureGate
|
||||||
|
lockedProps={{
|
||||||
|
title: "Self-hosted Restrictions",
|
||||||
|
description:
|
||||||
|
"Deployment and authentication restrictions are part of Dokploy Enterprise. Add a valid license to configure them.",
|
||||||
|
ctaLabel: "Go to License",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">
|
||||||
|
Self-hosted Restrictions
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control deployment targets and authentication behavior.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<ToggleRemoteServersOnly />
|
||||||
|
<ToggleEnforceSSO />
|
||||||
|
</CardContent>
|
||||||
|
</EnterpriseFeatureGate>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -76,6 +115,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
trpcState: helpers.dehydrate(),
|
trpcState: helpers.dehydrate(),
|
||||||
|
isCloud: IS_CLOUD,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
|
import {
|
||||||
|
getWebServerSettings,
|
||||||
|
IS_CLOUD,
|
||||||
|
isAdminPresent,
|
||||||
|
} from "@dokploy/server";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||||
@@ -52,8 +56,9 @@ type LoginForm = z.infer<typeof LoginSchema>;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
IS_CLOUD: boolean;
|
IS_CLOUD: boolean;
|
||||||
|
enforceSSO: boolean;
|
||||||
}
|
}
|
||||||
export default function Home({ IS_CLOUD }: Props) {
|
export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||||
@@ -247,7 +252,9 @@ export default function Home({ IS_CLOUD }: Props) {
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{!isTwoFactor ? (
|
{!isTwoFactor ? (
|
||||||
<>
|
<>
|
||||||
{showSignInWithSSO ? (
|
{enforceSSO ? (
|
||||||
|
<SignInWithSSO enforce />
|
||||||
|
) : showSignInWithSSO ? (
|
||||||
<SignInWithSSO>{loginContent}</SignInWithSSO>
|
<SignInWithSSO>{loginContent}</SignInWithSSO>
|
||||||
) : (
|
) : (
|
||||||
loginContent
|
loginContent
|
||||||
@@ -417,6 +424,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
IS_CLOUD: IS_CLOUD,
|
IS_CLOUD: IS_CLOUD,
|
||||||
|
enforceSSO: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -442,9 +450,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
hasAdmin,
|
hasAdmin,
|
||||||
|
enforceSSO: webServerSettings?.enforceSSO ?? false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
getApplicationStats,
|
getApplicationStats,
|
||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
mechanizeDockerContainer,
|
mechanizeDockerContainer,
|
||||||
readConfig,
|
readConfig,
|
||||||
@@ -87,7 +88,11 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create an application",
|
message: "You need to use a server to create an application",
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a compose",
|
message: "You need to use a server to create a compose",
|
||||||
@@ -585,7 +589,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, environment.projectId, "create");
|
await checkServiceAccess(ctx, environment.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a compose",
|
message: "You need to use a server to create a compose",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
findProjectById,
|
findProjectById,
|
||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removeLibsqlById,
|
removeLibsqlById,
|
||||||
@@ -51,7 +52,11 @@ export const libsqlRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a Libsql",
|
message: "You need to use a server to create a Libsql",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removeMariadbById,
|
removeMariadbById,
|
||||||
@@ -62,7 +63,11 @@ export const mariadbRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a Mariadb",
|
message: "You need to use a server to create a Mariadb",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removeMongoById,
|
removeMongoById,
|
||||||
@@ -61,7 +62,11 @@ export const mongoRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a mongo",
|
message: "You need to use a server to create a mongo",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removeMySqlById,
|
removeMySqlById,
|
||||||
@@ -62,7 +63,11 @@ export const mysqlRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a MySQL",
|
message: "You need to use a server to create a MySQL",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
getMountPath,
|
getMountPath,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removePostgresById,
|
removePostgresById,
|
||||||
@@ -63,7 +64,11 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a Postgres",
|
message: "You need to use a server to create a Postgres",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { normalizeTrustedOrigin } from "@dokploy/server";
|
|||||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
|
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
|
||||||
|
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||||
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
|
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
|
||||||
import {
|
import {
|
||||||
getOrganizationOwnerId,
|
getOrganizationOwnerId,
|
||||||
@@ -43,6 +44,13 @@ export const ssoRouter = createTRPCRouter({
|
|||||||
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
|
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
enforceSSO: publicProcedure.query(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const settings = await getWebServerSettings();
|
||||||
|
return settings?.enforceSSO ?? false;
|
||||||
|
}),
|
||||||
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||||
const providers = await db.query.ssoProvider.findMany({
|
const providers = await db.query.ssoProvider.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getAccessibleServerIds,
|
getAccessibleServerIds,
|
||||||
getContainerLogs,
|
getContainerLogs,
|
||||||
getServiceContainerCommand,
|
getServiceContainerCommand,
|
||||||
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removeRedisById,
|
removeRedisById,
|
||||||
@@ -59,7 +60,11 @@ export const redisRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await checkServiceAccess(ctx, project.projectId, "create");
|
await checkServiceAccess(ctx, project.projectId, "create");
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
const webServerSettings = await getWebServerSettings();
|
||||||
|
if (
|
||||||
|
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||||
|
!input.serverId
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You need to use a server to create a Redis",
|
message: "You need to use a server to create a Redis",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { appRouter } from "../root";
|
|||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
|
enterpriseProcedure,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
@@ -445,6 +446,50 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateRemoteServersOnly: enterpriseProcedure
|
||||||
|
.input(z.object({ remoteServersOnly: z.boolean() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "This feature is only available for self-hosted instances",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateWebServerSettings({
|
||||||
|
remoteServersOnly: input.remoteServersOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "update",
|
||||||
|
resourceType: "settings",
|
||||||
|
resourceName: "remote-servers-only",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateEnforceSSO: enterpriseProcedure
|
||||||
|
.input(z.object({ enforceSSO: z.boolean() }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "This feature is only available for self-hosted instances",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateWebServerSettings({
|
||||||
|
enforceSSO: input.enforceSSO,
|
||||||
|
});
|
||||||
|
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "update",
|
||||||
|
resourceType: "settings",
|
||||||
|
resourceName: "enforce-sso",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
|
||||||
readTraefikConfig: adminProcedure.query(() => {
|
readTraefikConfig: adminProcedure.query(() => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export const webServerSettings = pgTable("webServerSettings", {
|
|||||||
metaTitle: null,
|
metaTitle: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
}),
|
}),
|
||||||
|
// Deployment Configuration (self-hosted only)
|
||||||
|
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||||
|
// Auth Configuration (self-hosted only)
|
||||||
|
enforceSSO: boolean("enforceSSO").notNull().default(false),
|
||||||
// Cache Cleanup Configuration
|
// Cache Cleanup Configuration
|
||||||
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -155,6 +159,8 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
|||||||
cleanupCacheApplications: z.boolean().optional(),
|
cleanupCacheApplications: z.boolean().optional(),
|
||||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||||
cleanupCacheOnCompose: z.boolean().optional(),
|
cleanupCacheOnCompose: z.boolean().optional(),
|
||||||
|
remoteServersOnly: z.boolean().optional(),
|
||||||
|
enforceSSO: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiAssignDomain = z
|
export const apiAssignDomain = z
|
||||||
|
|||||||
Reference in New Issue
Block a user