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:
Mauricio Siu
2026-05-30 01:02:34 -06:00
committed by GitHub
parent 6675aa6f37
commit 8018027330
25 changed files with 16995 additions and 40 deletions

View File

@@ -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(),
}; };

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -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,
}, },
}; };
} }

View File

@@ -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,
}, },
}; };
} }

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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",

View File

@@ -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;

View File

@@ -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