mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-30 11:35:22 +02:00
Compare commits
1 Commits
feat/intro
...
feat/add-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d266b0840 |
@@ -23,7 +23,7 @@
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node": "^20.17.51",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -13,11 +13,11 @@ type MockCreateServiceOptions = {
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<() => Promise<never>>();
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<
|
||||
(opts: MockCreateServiceOptions) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
@@ -80,9 +80,7 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -99,9 +97,7 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mock the DB module so tests that import from @dokploy/server (barrel)
|
||||
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
|
||||
* Without this, loading the server barrel pulls in lib/auth and db, which
|
||||
* connect to localhost:5432 and cause ECONNREFUSED.
|
||||
*/
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const chain = () => chain;
|
||||
chain.set = () => chain;
|
||||
chain.where = () => chain;
|
||||
chain.values = () => chain;
|
||||
chain.returning = () => Promise.resolve([{}]);
|
||||
chain.then = undefined;
|
||||
|
||||
const tableMock = {
|
||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
insert: vi.fn(() => Promise.resolve([{}])),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
};
|
||||
const createQueryMock = () => tableMock;
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => chain),
|
||||
insert: vi.fn(() => ({
|
||||
values: () => ({ returning: () => Promise.resolve([{}]) }),
|
||||
})),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
query: new Proxy({} as Record<string, typeof tableMock>, {
|
||||
get: () => tableMock,
|
||||
}),
|
||||
},
|
||||
dbUrl: "postgres://mock:mock@localhost:5432/mock",
|
||||
};
|
||||
});
|
||||
@@ -7,15 +7,10 @@ export default defineConfig({
|
||||
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
||||
pool: "forks",
|
||||
setupFiles: [path.resolve(__dirname, "setup.ts")],
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE: "test",
|
||||
GITHUB_CLIENT_ID: "test",
|
||||
GITHUB_CLIENT_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -4,21 +4,35 @@ import { cn } from "@/lib/utils";
|
||||
import { GithubIcon } from "../icons/data-tools-icons";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Button } from "../ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||
const appName = whitelabel?.whitelabelAppName ?? "Dokploy";
|
||||
const logoUrl =
|
||||
whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl;
|
||||
|
||||
return (
|
||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
{whitelabel?.whitelabelLoginBackgroundImageUrl && (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url(${whitelabel.whitelabelLoginBackgroundImageUrl})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" />
|
||||
Dokploy
|
||||
<Logo className="size-10" logoUrl={logoUrl ?? undefined} />
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
@@ -404,8 +405,8 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/license",
|
||||
icon: Key,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -416,6 +417,15 @@ const MENU: Menu = {
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Whitelabeling",
|
||||
url: "/dashboard/settings/whitelabelling",
|
||||
icon: Palette,
|
||||
// Enterprise only – page shows gate if no license
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
],
|
||||
|
||||
help: [
|
||||
@@ -546,6 +556,7 @@ function SidebarLogo() {
|
||||
refetch,
|
||||
isLoading,
|
||||
} = api.organization.all.useQuery();
|
||||
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
|
||||
api.organization.delete.useMutation();
|
||||
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
|
||||
@@ -611,7 +622,11 @@ function SidebarLogo() {
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-4" : "size-5",
|
||||
)}
|
||||
logoUrl={activeOrganization?.logo || undefined}
|
||||
logoUrl={
|
||||
activeOrganization?.logo ||
|
||||
whitelabel?.whitelabelLogoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -621,7 +636,9 @@ function SidebarLogo() {
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeOrganization?.name ?? "Select Organization"}
|
||||
{activeOrganization?.name ??
|
||||
whitelabel?.whitelabelAppName ??
|
||||
"Select Organization"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,32 +101,27 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
||||
const scopes = data.scopes.filter(Boolean).length
|
||||
? data.scopes.filter(Boolean)
|
||||
: DEFAULT_SCOPES;
|
||||
|
||||
const isAzure = data.issuer.includes("login.microsoftonline.com");
|
||||
const mapping = isAzure
|
||||
? {
|
||||
id: "sub",
|
||||
email: "preferred_username",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
}
|
||||
: {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "preferred_username",
|
||||
image: "picture",
|
||||
};
|
||||
const domain = data.domains
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains: data.domains,
|
||||
domain,
|
||||
oidcConfig: {
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
scopes,
|
||||
pkce: true,
|
||||
mapping,
|
||||
// Keycloak (and many IdPs) send preferred_username; better-auth expects name
|
||||
mapping: {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "preferred_username",
|
||||
image: "picture",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -52,7 +52,12 @@ const samlProviderSchema = z.object({
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
cert: z.string().min(1, "IdP signing certificate is required"),
|
||||
idpMetadataXml: z.string().optional(),
|
||||
callbackUrl: z
|
||||
.string()
|
||||
.min(1, "Callback URL is required")
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
|
||||
});
|
||||
|
||||
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||
@@ -67,7 +72,8 @@ const formDefaultValues: SamlProviderForm = {
|
||||
domains: [""],
|
||||
entryPoint: "",
|
||||
cert: "",
|
||||
idpMetadataXml: "",
|
||||
callbackUrl: "",
|
||||
audience: "",
|
||||
};
|
||||
|
||||
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
@@ -75,14 +81,6 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseURL(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const form = useForm<SamlProviderForm>({
|
||||
resolver: zodResolver(samlProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
@@ -97,38 +95,24 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
|
||||
const onSubmit = async (data: SamlProviderForm) => {
|
||||
try {
|
||||
// maybe add the /saml/metadata endpoint to the baseURL
|
||||
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
|
||||
const generateSpMetadata = (providerId: string) => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
|
||||
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
|
||||
</md:SPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
};
|
||||
|
||||
const domain = data.domains
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains: data.domains,
|
||||
domain,
|
||||
samlConfig: {
|
||||
entryPoint: data.entryPoint,
|
||||
cert: data.cert,
|
||||
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
|
||||
audience: baseURL,
|
||||
idpMetadata: data.idpMetadataXml?.trim()
|
||||
? { metadata: data.idpMetadataXml.trim() }
|
||||
: undefined,
|
||||
callbackUrl: data.callbackUrl,
|
||||
audience: data.audience,
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: "sha256",
|
||||
digestAlgorithm: "sha256",
|
||||
spMetadata: {
|
||||
metadata: generateSpMetadata(data.providerId),
|
||||
},
|
||||
mapping: {
|
||||
id: "nameID",
|
||||
email: "email",
|
||||
name: "displayName",
|
||||
firstName: "givenName",
|
||||
lastName: "surname",
|
||||
entityID: data.audience,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -284,29 +268,39 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="idpMetadataXml"
|
||||
name="callbackUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP metadata XML (optional)</FormLabel>
|
||||
<FormLabel>Callback URL (ACS)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
<Input
|
||||
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Some IdPs require full metadata; paste the XML here to
|
||||
override issuer/entry point/cert.
|
||||
Use the callback URL shown in your IdP app config for this
|
||||
provider.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="audience"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audience (Entity ID)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourapp.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -340,10 +340,7 @@ export const SSOSettings = () => {
|
||||
Callback URL (configure in your IdP)
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
{baseURL || "{baseURL}"}
|
||||
{detailsProvider.samlConfig
|
||||
? "/api/auth/sso/saml2/callback/"
|
||||
: "/api/auth/sso/callback/"}
|
||||
{baseURL || "{baseURL}"}/api/auth/sso/callback/
|
||||
{detailsProvider.providerId}
|
||||
</p>
|
||||
{!baseURL && (
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2, Palette } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const whitelabelSchema = z.object({
|
||||
whitelabelAppName: z.string().min(1).max(100),
|
||||
whitelabelLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||
whitelabelLoginLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||
whitelabelFaviconUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||
whitelabelLoginTitle: z.string().max(200).optional(),
|
||||
whitelabelLoginSubtitle: z.string().max(500).optional(),
|
||||
whitelabelLoginBackgroundImageUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type WhitelabelFormValues = z.infer<typeof whitelabelSchema>;
|
||||
|
||||
export function WhitelabelSettings() {
|
||||
const { data: settings, isLoading } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const { mutateAsync: updateWhitelabel, isLoading: isSaving } =
|
||||
api.settings.updateWhitelabelSettings.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<WhitelabelFormValues>({
|
||||
resolver: zodResolver(whitelabelSchema),
|
||||
defaultValues: {
|
||||
whitelabelAppName: "Dokploy",
|
||||
whitelabelLogoUrl: "",
|
||||
whitelabelLoginLogoUrl: "",
|
||||
whitelabelFaviconUrl: "",
|
||||
whitelabelLoginTitle: "",
|
||||
whitelabelLoginSubtitle: "",
|
||||
whitelabelLoginBackgroundImageUrl: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.reset({
|
||||
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
|
||||
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? "",
|
||||
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? "",
|
||||
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? "",
|
||||
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? "",
|
||||
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? "",
|
||||
whitelabelLoginBackgroundImageUrl:
|
||||
settings.whitelabelLoginBackgroundImageUrl ?? "",
|
||||
});
|
||||
}
|
||||
}, [settings, form]);
|
||||
|
||||
const onSubmit = async (values: WhitelabelFormValues) => {
|
||||
try {
|
||||
await updateWhitelabel({
|
||||
whitelabelAppName: values.whitelabelAppName || null,
|
||||
whitelabelLogoUrl: values.whitelabelLogoUrl || undefined,
|
||||
whitelabelLoginLogoUrl: values.whitelabelLoginLogoUrl || undefined,
|
||||
whitelabelFaviconUrl: values.whitelabelFaviconUrl || undefined,
|
||||
whitelabelLoginTitle: values.whitelabelLoginTitle || null,
|
||||
whitelabelLoginSubtitle: values.whitelabelLoginSubtitle || null,
|
||||
whitelabelLoginBackgroundImageUrl:
|
||||
values.whitelabelLoginBackgroundImageUrl || undefined,
|
||||
});
|
||||
toast.success("Whitelabel settings saved");
|
||||
utils.settings.getWebServerSettings.invalidate();
|
||||
utils.settings.getWhitelabelSettings.invalidate();
|
||||
} catch (e) {
|
||||
toast.error("Failed to save whitelabel settings");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading whitelabel settings...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">Whitelabeling</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Customize the application name, logos, and login page for your brand.
|
||||
Leave URLs empty to use defaults.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Brand</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Application name and main logo (sidebar, header).
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelAppName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Dokploy"
|
||||
{...field}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLogoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Logo shown in the sidebar and header.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelFaviconUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-6 border-t">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Login page</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize the sign-in and registration screens.
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginLogoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/login-logo.png"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Logo on the login and register pages. Falls back to the main
|
||||
logo if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Sign in"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginSubtitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login subtitle</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your email and password to sign in"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginBackgroundImageUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login background image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/background.jpg"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional background image for the login page.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "licenseKey" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "trustedOrigins" text[];--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
2
apps/dokploy/drizzle/0137_naive_power_pack.sql
Normal file
2
apps/dokploy/drizzle/0137_naive_power_pack.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "licenseKey" text;
|
||||
13
apps/dokploy/drizzle/0138_common_mathemanic.sql
Normal file
13
apps/dokploy/drizzle/0138_common_mathemanic.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"user_id" text,
|
||||
"provider_id" text NOT NULL,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0139_smiling_havok.sql
Normal file
1
apps/dokploy/drizzle/0139_smiling_havok.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;
|
||||
1
apps/dokploy/drizzle/0140_great_lightspeed.sql
Normal file
1
apps/dokploy/drizzle/0140_great_lightspeed.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "e5c16e66-ec3d-4a91-b3ac-f9ea4577f53f",
|
||||
"id": "af1f5881-9a57-4f68-9ef2-632b0370b0c5",
|
||||
"prevId": "5958b029-1fb9-4a44-be24-c96b4e899b84",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -6309,102 +6309,6 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sso_provider": {
|
||||
"name": "sso_provider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"issuer": {
|
||||
"name": "issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_config": {
|
||||
"name": "oidc_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"saml_config": {
|
||||
"name": "saml_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"domain": {
|
||||
"name": "domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sso_provider_user_id_user_id_fk": {
|
||||
"name": "sso_provider_user_id_user_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"sso_provider_organization_id_organization_id_fk": {
|
||||
"name": "sso_provider_organization_id_organization_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organization_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sso_provider_provider_id_unique": {
|
||||
"name": "sso_provider_provider_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"provider_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
@@ -6537,13 +6441,6 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"isValidEnterpriseLicense": {
|
||||
"name": "isValidEnterpriseLicense",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
"type": "text",
|
||||
@@ -6562,12 +6459,6 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"trustedOrigins": {
|
||||
"name": "trustedOrigins",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
|
||||
7146
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
7146
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7153
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
7153
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7166
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
7166
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -964,8 +964,29 @@
|
||||
{
|
||||
"idx": 137,
|
||||
"version": "7",
|
||||
"when": 1770274109332,
|
||||
"tag": "0137_colossal_sally_floyd",
|
||||
"when": 1769616589728,
|
||||
"tag": "0137_naive_power_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 138,
|
||||
"version": "7",
|
||||
"when": 1769745328628,
|
||||
"tag": "0138_common_mathemanic",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 139,
|
||||
"version": "7",
|
||||
"when": 1769746948088,
|
||||
"tag": "0139_smiling_havok",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 140,
|
||||
"version": "7",
|
||||
"when": 1769854977685,
|
||||
"tag": "0140_great_lightspeed",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"i18next": "^23.16.8",
|
||||
@@ -117,7 +117,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "^16.1.6",
|
||||
"next": "^16.0.10",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
@@ -165,7 +165,7 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node": "^18.19.104",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -175,7 +175,7 @@
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"autoprefixer": "10.4.12",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"esbuild": "0.20.2",
|
||||
"lint-staged": "^15.5.2",
|
||||
"memfs": "^4.17.2",
|
||||
@@ -183,7 +183,7 @@
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-tsconfig-paths": "4.3.2",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^1.6.1"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.2"
|
||||
|
||||
@@ -36,6 +36,14 @@ export async function getServerSideProps(
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const locale = await getLocale(req.cookies);
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
|
||||
84
apps/dokploy/pages/dashboard/settings/whitelabelling.tsx
Normal file
84
apps/dokploy/pages/dashboard/settings/whitelabelling.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { WhitelabelSettings } from "@/components/proprietary/whitelabelling/whitelabel-settings";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="p-6">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Enterprise Whitelabeling",
|
||||
description:
|
||||
"Whitelabeling is part of Dokploy Enterprise. Add a valid license to customize logos, app name, and login page.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<WhitelabelSettings />
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req } = ctx;
|
||||
const locale = await getLocale(req.cookies);
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/settings/profile",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: ctx.res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
...(await serverSideTranslations(locale, ["settings"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,7 @@ interface Props {
|
||||
export default function Home({ IS_CLOUD }: Props) {
|
||||
const router = useRouter();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
||||
@@ -212,17 +213,27 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
</>
|
||||
);
|
||||
|
||||
const loginLogoUrl =
|
||||
whitelabel?.whitelabelLoginLogoUrl ?? whitelabel?.whitelabelLogoUrl;
|
||||
const loginTitle = whitelabel?.whitelabelLoginTitle ?? "Sign in";
|
||||
const loginSubtitle =
|
||||
whitelabel?.whitelabelLoginSubtitle ??
|
||||
"Enter your email and password to sign in";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Logo className="size-12" />
|
||||
Sign in
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={loginLogoUrl ?? undefined}
|
||||
/>
|
||||
{loginTitle}
|
||||
</div>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email and password to sign in
|
||||
{loginSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function Home() {
|
||||
|
||||
const onSubmit = async (values: Login) => {
|
||||
setIsLoading(true);
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
const { error } = await authClient.forgetPassword({
|
||||
email: values.email,
|
||||
redirectTo: "/reset-password",
|
||||
});
|
||||
|
||||
@@ -26,13 +26,6 @@ export const licenseKeyRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not authorized to activate a license key",
|
||||
});
|
||||
}
|
||||
|
||||
if (!currentUser.enableEnterpriseFeatures) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -124,14 +117,6 @@ export const licenseKeyRouter = createTRPCRouter({
|
||||
message: "No license key found",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not authorized to deactivate a license key",
|
||||
});
|
||||
}
|
||||
|
||||
await deactivateLicenseKey(currentUser.licenseKey);
|
||||
await db
|
||||
.update(user)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { normalizeTrustedOrigin } from "@dokploy/server";
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
|
||||
import { member, ssoProvider } from "@dokploy/server/db/schema";
|
||||
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
|
||||
import { requestToHeaders } from "@dokploy/server/index";
|
||||
import { auth } from "@dokploy/server/lib/auth";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
@@ -14,6 +12,20 @@ import {
|
||||
} from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
|
||||
function requestToHeaders(req: {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
}): Headers {
|
||||
const headers = new Headers();
|
||||
if (req?.headers) {
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value !== undefined && key.toLowerCase() !== "host") {
|
||||
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const ssoRouter = createTRPCRouter({
|
||||
showSignInWithSSO: publicProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
@@ -61,28 +73,6 @@ export const ssoRouter = createTRPCRouter({
|
||||
deleteProvider: enterpriseProcedure
|
||||
.input(z.object({ providerId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Obtener el provider antes de eliminarlo para obtener sus dominios
|
||||
const providerToDelete = await db.query.ssoProvider.findFirst({
|
||||
where: and(
|
||||
eq(ssoProvider.providerId, input.providerId),
|
||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(ssoProvider.userId, ctx.session.userId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
domain: true,
|
||||
issuer: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!providerToDelete) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"SSO provider not found or you do not have permission to delete it",
|
||||
});
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(ssoProvider)
|
||||
.where(
|
||||
@@ -102,24 +92,6 @@ export const ssoRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const currentUser = await db.query.user.findFirst({
|
||||
where: eq(user.id, ctx.session.userId),
|
||||
columns: {
|
||||
trustedOrigins: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentUser?.trustedOrigins) {
|
||||
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
|
||||
const updatedOrigins = currentUser.trustedOrigins.filter(
|
||||
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
|
||||
);
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({ trustedOrigins: updatedOrigins })
|
||||
.where(eq(user.id, ctx.session.userId));
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
register: enterpriseProcedure
|
||||
@@ -127,54 +99,15 @@ export const ssoRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const organizationId = ctx.session.activeOrganizationId;
|
||||
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
columns: {
|
||||
domain: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerDomains = provider.domain
|
||||
.split(",")
|
||||
.map((d) => d.trim().toLowerCase());
|
||||
for (const domain of input.domains) {
|
||||
if (providerDomains.includes(domain)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Domain ${domain} is already registered for another provider`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const domain = input.domains.join(",");
|
||||
const currentUser = await db.query.user.findFirst({
|
||||
where: eq(user.id, ctx.session.userId),
|
||||
columns: {
|
||||
trustedOrigins: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingOrigins = currentUser?.trustedOrigins || [];
|
||||
|
||||
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
|
||||
|
||||
const newOrigins = Array.from(
|
||||
new Set([...existingOrigins, issuerOrigin]),
|
||||
);
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({ trustedOrigins: newOrigins })
|
||||
.where(eq(user.id, ctx.session.userId));
|
||||
|
||||
await auth.registerSSOProvider({
|
||||
const result = await auth.registerSSOProvider({
|
||||
body: {
|
||||
...input,
|
||||
organizationId,
|
||||
domain,
|
||||
},
|
||||
headers: requestToHeaders(ctx.req),
|
||||
});
|
||||
console.log(result);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
apiServerSchema,
|
||||
apiTraefikConfig,
|
||||
apiUpdateDockerCleanup,
|
||||
apiUpdateWhitelabel,
|
||||
projects,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
@@ -72,6 +73,7 @@ import { appRouter } from "../root";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
enterpriseProcedure,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
@@ -84,6 +86,57 @@ export const settingsRouter = createTRPCRouter({
|
||||
const settings = await getWebServerSettings();
|
||||
return settings;
|
||||
}),
|
||||
getWhitelabelSettings: publicProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
if (!settings) return null;
|
||||
return {
|
||||
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
|
||||
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? null,
|
||||
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? null,
|
||||
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? null,
|
||||
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? null,
|
||||
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? null,
|
||||
whitelabelLoginBackgroundImageUrl:
|
||||
settings.whitelabelLoginBackgroundImageUrl ?? null,
|
||||
};
|
||||
}),
|
||||
updateWhitelabelSettings: enterpriseProcedure
|
||||
.input(apiUpdateWhitelabel)
|
||||
.mutation(async ({ input }) => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (input.whitelabelAppName !== undefined)
|
||||
updates.whitelabelAppName = input.whitelabelAppName;
|
||||
if (input.whitelabelLogoUrl !== undefined)
|
||||
updates.whitelabelLogoUrl =
|
||||
input.whitelabelLogoUrl === "" ? null : input.whitelabelLogoUrl;
|
||||
if (input.whitelabelLoginLogoUrl !== undefined)
|
||||
updates.whitelabelLoginLogoUrl =
|
||||
input.whitelabelLoginLogoUrl === ""
|
||||
? null
|
||||
: input.whitelabelLoginLogoUrl;
|
||||
if (input.whitelabelFaviconUrl !== undefined)
|
||||
updates.whitelabelFaviconUrl =
|
||||
input.whitelabelFaviconUrl === ""
|
||||
? null
|
||||
: input.whitelabelFaviconUrl;
|
||||
if (input.whitelabelLoginTitle !== undefined)
|
||||
updates.whitelabelLoginTitle = input.whitelabelLoginTitle;
|
||||
if (input.whitelabelLoginSubtitle !== undefined)
|
||||
updates.whitelabelLoginSubtitle = input.whitelabelLoginSubtitle;
|
||||
if (input.whitelabelLoginBackgroundImageUrl !== undefined)
|
||||
updates.whitelabelLoginBackgroundImageUrl =
|
||||
input.whitelabelLoginBackgroundImageUrl === ""
|
||||
? null
|
||||
: input.whitelabelLoginBackgroundImageUrl;
|
||||
const updated = await updateWebServerSettings(updates as any);
|
||||
return updated;
|
||||
}),
|
||||
reloadServer: adminProcedure.mutation(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
* need to use are documented accordingly near the end.
|
||||
*/
|
||||
|
||||
import { user as userSchema } from "@dokploy/server/db/schema";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
import {
|
||||
@@ -31,14 +33,7 @@ import { db } from "@/server/db";
|
||||
*/
|
||||
|
||||
interface CreateContextOptions {
|
||||
user:
|
||||
| (User & {
|
||||
role: "member" | "admin" | "owner";
|
||||
ownerId: string;
|
||||
enableEnterpriseFeatures: boolean;
|
||||
isValidEnterpriseLicense: boolean;
|
||||
})
|
||||
| null;
|
||||
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
|
||||
session:
|
||||
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
|
||||
| null;
|
||||
@@ -239,9 +234,17 @@ export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const currentUser = await ctx.db.query.user.findFirst({
|
||||
where: eq(userSchema.id, ctx.user.id),
|
||||
columns: {
|
||||
enableEnterpriseFeatures: true,
|
||||
isValidEnterpriseLicense: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!ctx.user?.enableEnterpriseFeatures ||
|
||||
!ctx.user.isValidEnterpriseLicense
|
||||
!currentUser?.enableEnterpriseFeatures ||
|
||||
!currentUser.isValidEnterpriseLicense
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getPublicIpWithFallback, LICENSE_KEY_URL } from "@dokploy/server";
|
||||
import { getPublicIpWithFallback } from "@dokploy/server/index";
|
||||
|
||||
const LICENSE_KEY_URL = process.env.LICENSE_KEY_URL || "http://localhost:4002";
|
||||
|
||||
export const validateLicenseKey = async (licenseKey: string) => {
|
||||
try {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"bullmq": "5.4.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"hono": "^4.7.10",
|
||||
"ioredis": "5.4.1",
|
||||
"pino": "9.4.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node": "^20.17.51",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.1",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node": "^18.19.104",
|
||||
"dotenv": "16.4.5",
|
||||
"esbuild": "0.20.2",
|
||||
"lint-staged": "^15.5.2",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@ai-sdk/mistral": "^2.0.7",
|
||||
"@ai-sdk/openai": "^2.0.16",
|
||||
"@ai-sdk/openai-compatible": "^1.0.10",
|
||||
"@better-auth/utils": "0.3.0",
|
||||
"@better-auth/utils": "0.2.4",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
@@ -57,7 +57,7 @@
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"yaml": "2.8.1",
|
||||
"lodash": "4.17.21",
|
||||
@@ -91,7 +91,7 @@
|
||||
"@types/dockerode": "3.3.23",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node": "^18.19.104",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -100,7 +100,7 @@
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild-plugin-alias": "0.2.1",
|
||||
"postcss": "^8.5.3",
|
||||
|
||||
@@ -26,8 +26,7 @@ if (DATABASE_URL) {
|
||||
password,
|
||||
)}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
console.warn(`
|
||||
console.warn(`
|
||||
⚠️ [DEPRECATED DATABASE CONFIG]
|
||||
You are using the legacy hardcoded database credentials.
|
||||
This mode WILL BE REMOVED in a future release.
|
||||
@@ -35,13 +34,5 @@ if (DATABASE_URL) {
|
||||
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
|
||||
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
|
||||
`);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
dbUrl =
|
||||
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
|
||||
} else {
|
||||
dbUrl =
|
||||
"postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
|
||||
}
|
||||
dbUrl = "postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
|
||||
}
|
||||
|
||||
@@ -27,22 +27,11 @@ export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
|
||||
|
||||
export const ssoProviderBodySchema = z.object({
|
||||
providerId: z.string({}),
|
||||
issuer: z.string({}),
|
||||
domains: z
|
||||
.string()
|
||||
.array()
|
||||
.transform((val) =>
|
||||
Array.from(
|
||||
new Set(val.map((d) => d.trim().toLowerCase()).filter(Boolean)),
|
||||
),
|
||||
)
|
||||
.refine((val) => val.every((d) => domainRegex.test(d)), {
|
||||
message: "Invalid domain",
|
||||
path: ["domains"],
|
||||
}),
|
||||
domain: z.string({}),
|
||||
oidcConfig: z
|
||||
.object({
|
||||
clientId: z.string({}),
|
||||
|
||||
@@ -65,7 +65,6 @@ export const user = pgTable("user", {
|
||||
stripeCustomerId: text("stripeCustomerId"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
serversQuantity: integer("serversQuantity").notNull().default(0),
|
||||
trustedOrigins: text("trustedOrigins").array(),
|
||||
});
|
||||
|
||||
export const usersRelations = relations(user, ({ one, many }) => ({
|
||||
@@ -86,8 +85,6 @@ const createSchema = createInsertSchema(user, {
|
||||
isRegistered: z.boolean().optional(),
|
||||
}).omit({
|
||||
role: true,
|
||||
trustedOrigins: true,
|
||||
isValidEnterpriseLicense: true,
|
||||
});
|
||||
|
||||
export const apiCreateUserInvitation = createSchema.pick({}).extend({
|
||||
|
||||
@@ -76,6 +76,14 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
|
||||
.notNull()
|
||||
.default(false),
|
||||
// Whitelabel (Enterprise)
|
||||
whitelabelAppName: text("whitelabelAppName").default("Dokploy"),
|
||||
whitelabelLogoUrl: text("whitelabelLogoUrl"),
|
||||
whitelabelLoginLogoUrl: text("whitelabelLoginLogoUrl"),
|
||||
whitelabelFaviconUrl: text("whitelabelFaviconUrl"),
|
||||
whitelabelLoginTitle: text("whitelabelLoginTitle"),
|
||||
whitelabelLoginSubtitle: text("whitelabelLoginSubtitle"),
|
||||
whitelabelLoginBackgroundImageUrl: text("whitelabelLoginBackgroundImageUrl"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -125,6 +133,18 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
||||
cleanupCacheApplications: z.boolean().optional(),
|
||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||
cleanupCacheOnCompose: z.boolean().optional(),
|
||||
whitelabelAppName: z.string().optional().nullable(),
|
||||
whitelabelLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||
whitelabelLoginLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||
whitelabelFaviconUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||
whitelabelLoginTitle: z.string().optional().nullable(),
|
||||
whitelabelLoginSubtitle: z.string().optional().nullable(),
|
||||
whitelabelLoginBackgroundImageUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.nullable()
|
||||
.or(z.literal("")),
|
||||
});
|
||||
|
||||
export const apiAssignDomain = z
|
||||
@@ -154,6 +174,21 @@ export const apiUpdateDockerCleanup = z.object({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiUpdateWhitelabel = z.object({
|
||||
whitelabelAppName: z.string().min(1).max(100).optional().nullable(),
|
||||
whitelabelLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||
whitelabelLoginLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||
whitelabelFaviconUrl: z.string().url().optional().nullable().or(z.literal("")),
|
||||
whitelabelLoginTitle: z.string().max(200).optional().nullable(),
|
||||
whitelabelLoginSubtitle: z.string().max(500).optional().nullable(),
|
||||
whitelabelLoginBackgroundImageUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.nullable()
|
||||
.or(z.literal("")),
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerMonitoring = z.object({
|
||||
metricsConfig: z
|
||||
.object({
|
||||
|
||||
@@ -9,7 +9,8 @@ import { and, desc, eq } from "drizzle-orm";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import { getTrustedOrigins, getUserByToken } from "../services/admin";
|
||||
import { getUserByToken } from "../services/admin";
|
||||
import { getSSOProviders } from "../services/proprietary/sso";
|
||||
import {
|
||||
getWebServerSettings,
|
||||
updateWebServerSettings,
|
||||
@@ -18,7 +19,7 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||
import { sendEmail } from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
|
||||
const { handler, api } = betterAuth({
|
||||
export const { handler, api } = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: schema,
|
||||
@@ -43,24 +44,29 @@ const { handler, api } = betterAuth({
|
||||
logger: {
|
||||
disabled: process.env.NODE_ENV === "production",
|
||||
},
|
||||
async trustedOrigins() {
|
||||
const trustedOrigins = await getTrustedOrigins();
|
||||
if (IS_CLOUD) {
|
||||
return trustedOrigins;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
if (!settings) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||
...(process.env.NODE_ENV === "development"
|
||||
? ["http://localhost:3000"]
|
||||
: []),
|
||||
...trustedOrigins,
|
||||
];
|
||||
},
|
||||
...(!IS_CLOUD && {
|
||||
async trustedOrigins() {
|
||||
const settings = await getWebServerSettings();
|
||||
if (!settings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const providers = await getSSOProviders();
|
||||
const domains = providers.map((provider) => provider.issuer);
|
||||
return [
|
||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||
...domains.map((domain) => domain),
|
||||
...(process.env.NODE_ENV === "development"
|
||||
? [
|
||||
"http://localhost:3000",
|
||||
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||
"https://dev-pee8hhc3qbjlqedb.us.auth0.com",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
}),
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
@@ -113,8 +119,8 @@ const { handler, api } = betterAuth({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
if (isSSORequest) {
|
||||
const isSSORequest = context?.path.includes("/sso/callback");
|
||||
if (!isSSORequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
@@ -129,7 +135,7 @@ const { handler, api } = betterAuth({
|
||||
}
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSSORequest = context?.path.includes("/sso/callback");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
@@ -269,16 +275,6 @@ const { handler, api } = betterAuth({
|
||||
input: true,
|
||||
defaultValue: "",
|
||||
},
|
||||
enableEnterpriseFeatures: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
isValidEnterpriseLicense: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
@@ -399,8 +395,6 @@ export const validateRequest = async (request: IncomingMessage) => {
|
||||
twoFactorEnabled: userFromDb.twoFactorEnabled,
|
||||
role: member?.role || "member",
|
||||
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
|
||||
enableEnterpriseFeatures: userFromDb.enableEnterpriseFeatures,
|
||||
isValidEnterpriseLicense: userFromDb.isValidEnterpriseLicense,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -439,15 +433,10 @@ export const validateRequest = async (request: IncomingMessage) => {
|
||||
),
|
||||
with: {
|
||||
organization: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
session.user.role = member?.role || "member";
|
||||
session.user.enableEnterpriseFeatures =
|
||||
member?.user.enableEnterpriseFeatures || false;
|
||||
session.user.isValidEnterpriseLicense =
|
||||
member?.user.isValidEnterpriseLicense || false;
|
||||
if (member) {
|
||||
session.user.ownerId = member.organization.ownerId;
|
||||
} else {
|
||||
|
||||
@@ -116,22 +116,3 @@ export const getDokployUrl = async () => {
|
||||
}
|
||||
return `http://${settings?.serverIp}:${process.env.PORT}`;
|
||||
};
|
||||
|
||||
export const getTrustedOrigins = async () => {
|
||||
const members = await db.query.member.findMany({
|
||||
where: eq(member.role, "owner"),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (members.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trustedOrigins = members.flatMap(
|
||||
(member) => member.user.trustedOrigins || [],
|
||||
);
|
||||
|
||||
return Array.from(new Set(trustedOrigins));
|
||||
};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
|
||||
export const getSSOProviders = async () => {
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
providerId: true,
|
||||
issuer: true,
|
||||
domain: true,
|
||||
oidcConfig: true,
|
||||
samlConfig: true,
|
||||
},
|
||||
});
|
||||
return providers;
|
||||
};
|
||||
|
||||
export const requestToHeaders = (req: {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
}): Headers => {
|
||||
const headers = new Headers();
|
||||
if (req?.headers) {
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value !== undefined && key.toLowerCase() !== "host") {
|
||||
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const normalizeTrustedOrigin = (value: string): string => {
|
||||
// Keep it simple: trim and remove trailing slashes.
|
||||
// e.g. "https://example.com/" -> "https://example.com"
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
};
|
||||
15
packages/server/src/services/proprietary/sso.tsx
Normal file
15
packages/server/src/services/proprietary/sso.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
|
||||
export const getSSOProviders = async () => {
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
providerId: true,
|
||||
issuer: true,
|
||||
domain: true,
|
||||
oidcConfig: true,
|
||||
samlConfig: true,
|
||||
},
|
||||
});
|
||||
return providers;
|
||||
};
|
||||
@@ -103,7 +103,7 @@ export const getProviderHeaders = (
|
||||
// Mistral
|
||||
if (apiUrl.includes("mistral")) {
|
||||
return {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Authorization: apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@ import { scheduleJob } from "node-schedule";
|
||||
import { db } from "../../db/index";
|
||||
import { user as userSchema } from "../../db/schema/user";
|
||||
|
||||
export const LICENSE_KEY_URL =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:4002"
|
||||
: "https://licenses.dokploy.com";
|
||||
|
||||
export const initEnterpriseBackupCronJobs = async () => {
|
||||
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
||||
const users = await db.query.user.findMany({
|
||||
@@ -44,13 +39,16 @@ export const initEnterpriseBackupCronJobs = async () => {
|
||||
export const validateLicenseKey = async (licenseKey: string) => {
|
||||
try {
|
||||
const ip = await getPublicIpWithFallback();
|
||||
const result = await fetch(`${LICENSE_KEY_URL}/licenses/validate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const result = await fetch(
|
||||
`${process.env.LICENSE_KEY_URL || "http://localhost:4002"}/licenses/validate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ licenseKey, ip }),
|
||||
},
|
||||
body: JSON.stringify({ licenseKey, ip }),
|
||||
});
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
const errorData = await result.json().catch(() => ({}));
|
||||
|
||||
1914
pnpm-lock.yaml
generated
1914
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user