mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16c22c74fd | |||
| e0006758c0 | |||
| d9fe046396 | |||
| bfe24f89f9 | |||
| 502d6c8a18 | |||
| 9e87b9e401 | |||
| a7d8dff360 | |||
| 94f3815eb4 | |||
| 931f5c1335 | |||
| aae2dd9377 | |||
| fe3912dc4b | |||
| ccd26e946b | |||
| 29107d88fc | |||
| c7ee0430f7 | |||
| 3cc8158fdc | |||
| 726664ab3c | |||
| 20701ba272 | |||
| 412516d47b | |||
| b4681b318f | |||
| 0898642e32 | |||
| d2779136c5 | |||
| 0b537a815d | |||
| 2674eb200d | |||
| e8f236c9db | |||
| 749bb2c1ce | |||
| 9992659f24 | |||
| 88cb057e06 | |||
| 7289a3f68e | |||
| 5e0b021ec2 |
@@ -10,12 +10,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"inngest": "3.40.1",
|
||||
"inngest": "3.54.0",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/node-server": "^1.19.15",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.11.7",
|
||||
"hono": "^4.13.5",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "19.2.7",
|
||||
|
||||
@@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => {
|
||||
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
|
||||
it("returns only the configured zone without listing every zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listRecords", () => {
|
||||
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
|
||||
"Invalid signature",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the configured zone instead of listing all zones", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess({ name: "example.com" }));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient with a configured zone", () => {
|
||||
const record = {
|
||||
type: "A" as const,
|
||||
name: "app.other.org",
|
||||
content: "1.2.3.4",
|
||||
};
|
||||
|
||||
it("refuses record operations on any other zone without calling OVH", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const message = /restricted to the zone "example.com"/;
|
||||
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
await expect(
|
||||
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
|
||||
).rejects.toThrow(message);
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "other.org", "1", record),
|
||||
).rejects.toThrow(message);
|
||||
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still allows operations on the configured zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "1"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(apiCalls()[0]?.[0]).toBe(
|
||||
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
provider: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/gitlab", () => ({
|
||||
findGitlabById: vi.fn(async () => ({ ...store.provider })),
|
||||
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
|
||||
store.provider = { ...store.provider, ...values };
|
||||
}),
|
||||
}));
|
||||
|
||||
const { refreshGitlabToken } = await import(
|
||||
"@dokploy/server/utils/providers/gitlab"
|
||||
);
|
||||
|
||||
const GITLAB_ID = "gitlab-1";
|
||||
|
||||
// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used
|
||||
const createGitlab = () => {
|
||||
let validRefreshToken = "refresh-1";
|
||||
let issued = 1;
|
||||
let failNext = false;
|
||||
|
||||
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
// Keeps the request open, so concurrent callers overlap as two deployments do
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
}
|
||||
|
||||
if (body.get("refresh_token") !== validRefreshToken) {
|
||||
return new Response(null, { status: 400, statusText: "Bad Request" });
|
||||
}
|
||||
|
||||
issued++;
|
||||
validRefreshToken = `refresh-${issued}`;
|
||||
return Response.json({
|
||||
access_token: `access-${issued}`,
|
||||
refresh_token: validRefreshToken,
|
||||
expires_in: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
fetch,
|
||||
failNextRequest: () => {
|
||||
failNext = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("refreshGitlabToken", () => {
|
||||
let gitlab: ReturnType<typeof createGitlab>;
|
||||
|
||||
beforeEach(() => {
|
||||
store.provider = {
|
||||
gitlabId: GITLAB_ID,
|
||||
gitlabUrl: "https://gitlab.example.com",
|
||||
gitlabInternalUrl: null,
|
||||
applicationId: "app-id",
|
||||
secret: "app-secret",
|
||||
accessToken: "access-1",
|
||||
refreshToken: "refresh-1",
|
||||
expiresAt: 0,
|
||||
};
|
||||
gitlab = createGitlab();
|
||||
vi.stubGlobal("fetch", gitlab.fetch);
|
||||
});
|
||||
|
||||
it("sends one refresh request when two callers find the token expired at the same time", async () => {
|
||||
await Promise.all([
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
]);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
|
||||
it("refreshes again after a failed refresh", async () => {
|
||||
gitlab.failNextRequest();
|
||||
|
||||
await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow(
|
||||
"Failed to refresh token: Internal Server Error",
|
||||
);
|
||||
await refreshGitlabToken(GITLAB_ID);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,10 @@ const baseSettings: WebServerSettings = {
|
||||
errorPageDescription: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
|
||||
@@ -554,7 +554,10 @@ export const RestoreBackup = ({
|
||||
value={field.value}
|
||||
onValueChange={(value: DatabaseType) => {
|
||||
field.onChange(value);
|
||||
form.setValue("metadata", {});
|
||||
form.setValue("metadata", {
|
||||
serviceName:
|
||||
form.getValues("metadata.serviceName") ?? "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || undefined}
|
||||
value={field.value ?? ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -45,13 +45,19 @@ function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
href,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
||||
const content = (
|
||||
<div
|
||||
className={`rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between h-full ${
|
||||
href ? "transition-colors hover:bg-muted/40 cursor-pointer" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
@@ -63,6 +69,16 @@ function StatCard({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block h-full">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function StatusListCard({
|
||||
@@ -182,6 +198,7 @@ export const ShowHome = () => {
|
||||
label="Projects"
|
||||
value={String(totals.projects)}
|
||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||
href="/dashboard/projects"
|
||||
/>
|
||||
<StatCard
|
||||
label="Services"
|
||||
|
||||
@@ -186,6 +186,7 @@ export const ImpersonationBar = () => {
|
||||
<Logo
|
||||
className="w-10 h-10"
|
||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
|
||||
/>
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
|
||||
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" &&
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template?.tags?.map((tag) => (
|
||||
{template.tags.slice(0, 6).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{template.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="green"
|
||||
className="text-[10px] px-2 py-0"
|
||||
title={template.tags.slice(6).join(", ")}
|
||||
>
|
||||
+{template.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
|
||||
applicationKey: z.string(),
|
||||
applicationSecret: z.string(),
|
||||
consumerKey: z.string(),
|
||||
zone: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
@@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = {
|
||||
applicationKey: "",
|
||||
applicationSecret: "",
|
||||
consumerKey: "",
|
||||
zone: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
@@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
applicationKey: data.applicationKey,
|
||||
applicationSecret: data.applicationSecret,
|
||||
consumerKey: data.consumerKey,
|
||||
zone: data.zone.trim() || undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
applicationKey: provider.config.applicationKey,
|
||||
applicationSecret: provider.config.applicationSecret,
|
||||
consumerKey: provider.config.consumerKey,
|
||||
zone: provider.config.zone ?? "",
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
@@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Zone (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Restrict this provider to a single DNS zone: Dokploy
|
||||
will only list and change records in it. Leave empty to
|
||||
use every zone the credentials can access. When set, the{" "}
|
||||
<code>GET /domain/zone</code> right is not needed. This
|
||||
is enforced by Dokploy only, so also limit the OVH token
|
||||
to that zone if you need a hard guarantee.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||
const logoUrl =
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||
const logoLightUrl =
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined;
|
||||
|
||||
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">
|
||||
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
href="/"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" logoUrl={logoUrl} />
|
||||
<Logo
|
||||
className="size-10"
|
||||
logoUrl={logoUrl}
|
||||
logoLightUrl={logoLightUrl}
|
||||
/>
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps {
|
||||
config: {
|
||||
appName?: string;
|
||||
logoUrl?: string;
|
||||
logoLightUrl?: string;
|
||||
dualModeBranding?: boolean;
|
||||
footerText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
const appName = config.appName || "Dokploy";
|
||||
const { resolvedTheme } = useTheme();
|
||||
const effectiveLogoUrl =
|
||||
config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl
|
||||
? config.logoLightUrl
|
||||
: config.logoUrl;
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{/* Simulated sidebar header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||
{config.logoUrl ? (
|
||||
{effectiveLogoUrl ? (
|
||||
<img
|
||||
src={config.logoUrl}
|
||||
src={effectiveLogoUrl}
|
||||
alt="Preview Logo"
|
||||
className="size-8 rounded-sm object-contain"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||
@@ -36,20 +37,42 @@ const safeUrlField = z
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: safeUrlField,
|
||||
faviconUrl: safeUrlField,
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: safeUrlField,
|
||||
supportUrl: safeUrlField,
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: safeUrlField,
|
||||
faviconUrl: safeUrlField,
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: safeUrlField,
|
||||
supportUrl: safeUrlField,
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
logoLightUrl: z.string(),
|
||||
loginLogoLightUrl: z.string(),
|
||||
faviconLightUrl: z.string(),
|
||||
dualModeBranding: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.dualModeBranding) return;
|
||||
for (const field of [
|
||||
"logoLightUrl",
|
||||
"loginLogoLightUrl",
|
||||
"faviconLightUrl",
|
||||
] as const) {
|
||||
const val = data[field];
|
||||
if (val !== "" && !/^https?:\/\//i.test(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
path: [field],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -195,6 +218,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: "",
|
||||
footerText: "",
|
||||
ogImageUrl: "",
|
||||
logoLightUrl: "",
|
||||
loginLogoLightUrl: "",
|
||||
faviconLightUrl: "",
|
||||
dualModeBranding: false,
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
@@ -214,6 +241,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
ogImageUrl: data.ogImageUrl ?? "",
|
||||
logoLightUrl: data.logoLightUrl ?? "",
|
||||
loginLogoLightUrl: data.loginLogoLightUrl ?? "",
|
||||
faviconLightUrl: data.faviconLightUrl ?? "",
|
||||
dualModeBranding: data.dualModeBranding ?? false,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -244,6 +275,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
footerText: values.footerText || null,
|
||||
ogImageUrl: values.ogImageUrl || null,
|
||||
logoLightUrl: values.logoLightUrl || null,
|
||||
loginLogoLightUrl: values.loginLogoLightUrl || null,
|
||||
faviconLightUrl: values.faviconLightUrl || null,
|
||||
dualModeBranding: values.dualModeBranding,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -412,6 +447,111 @@ export function WhitelabelingSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Light Mode Logos Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle>Light Mode Logos</CardTitle>
|
||||
<CardDescription>
|
||||
Use separate logos for light and dark themes. When enabled,
|
||||
the logos in Branding are used for dark mode.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dualModeBranding"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch
|
||||
aria-label="Enable light mode logos"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.clearErrors([
|
||||
"logoLightUrl",
|
||||
"loginLogoLightUrl",
|
||||
"faviconLightUrl",
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div
|
||||
className="grid transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
gridTemplateRows: form.watch("dualModeBranding")
|
||||
? "1fr"
|
||||
: "0fr",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo-light.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="loginLogoLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login Page Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/login-logo-light.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="faviconLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/favicon-light.ico"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Follows the browser/OS theme, not the website theme.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
logoUrl?: string;
|
||||
logoLightUrl?: string;
|
||||
}
|
||||
|
||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
||||
if (logoUrl) {
|
||||
export const Logo = ({
|
||||
className = "size-14",
|
||||
logoUrl,
|
||||
logoLightUrl,
|
||||
}: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const effectiveUrl =
|
||||
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
|
||||
|
||||
if (effectiveUrl) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
|
||||
<img
|
||||
src={logoUrl}
|
||||
src={effectiveUrl}
|
||||
alt="Organization Logo"
|
||||
className={cn(className, "object-contain rounded-sm")}
|
||||
/>
|
||||
|
||||
+14
-17
@@ -67,7 +67,7 @@
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.18",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@faker-js/faker": "^10.5.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/webhooks": "^13.9.0",
|
||||
@@ -87,7 +87,7 @@
|
||||
"@xterm/addon-attach": "0.10.0",
|
||||
"@xterm/addon-clipboard": "0.1.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"adm-zip": "^0.6.1",
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
@@ -100,24 +100,24 @@
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.13",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "4.17.21",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.3.4",
|
||||
"nanoid": "3.3.18",
|
||||
"next": "16.3.8",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.1.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"nodemailer": "10.0.15",
|
||||
"octokit": "3.1.2",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
@@ -135,7 +135,7 @@
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shadcn": "^4.11.0",
|
||||
"shell-quote": "^1.8.4",
|
||||
"shell-quote": "^1.11.0",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "~1.16.0",
|
||||
@@ -144,19 +144,18 @@
|
||||
"swagger-ui-react": "^5.32.6",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"toml": "3.0.0",
|
||||
"toml": "4.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"undici": "^6.21.3",
|
||||
"undici": "^6.28.0",
|
||||
"use-resize-observer": "9.1.0",
|
||||
"ws": "8.16.0",
|
||||
"ws": "8.21.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yaml": "2.8.1",
|
||||
"yaml": "2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
"zod-form-data": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -164,17 +163,15 @@
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"memfs": "^4.17.2",
|
||||
"simple-icons": "^16.14.0",
|
||||
|
||||
@@ -13,6 +13,7 @@ interface WhitelabelingDocumentProps {
|
||||
appDescription: string | null;
|
||||
ogImageUrl: string | null;
|
||||
faviconHref: string | null;
|
||||
faviconLightHref: string | null;
|
||||
customCss: string | null;
|
||||
baseUrl: string;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ export default function Document({
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
faviconLightHref,
|
||||
customCss,
|
||||
baseUrl,
|
||||
}: WhitelabelingDocumentProps) {
|
||||
@@ -44,7 +46,22 @@ export default function Document({
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||
{faviconLightHref ? (
|
||||
<>
|
||||
<link
|
||||
rel="icon"
|
||||
href={faviconHref || "/icon.svg"}
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href={faviconLightHref}
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||
)}
|
||||
{customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
@@ -69,6 +86,7 @@ declare global {
|
||||
appDescription: string | null;
|
||||
ogImageUrl: string | null;
|
||||
faviconHref: string | null;
|
||||
faviconLightHref: string | null;
|
||||
customCss: string | null;
|
||||
};
|
||||
expiresAt: number;
|
||||
@@ -84,6 +102,7 @@ Document.getInitialProps = async (
|
||||
let appDescription: string | null = null;
|
||||
let ogImageUrl: string | null = null;
|
||||
let faviconHref: string | null = null;
|
||||
let faviconLightHref: string | null = null;
|
||||
let customCss: string | null = null;
|
||||
|
||||
const host = ctx.req?.headers?.host || "localhost:3000";
|
||||
@@ -113,6 +132,7 @@ Document.getInitialProps = async (
|
||||
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
|
||||
: null;
|
||||
faviconHref = config.faviconUrl || null;
|
||||
faviconLightHref = config.faviconLightUrl || null;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to defaults if settings can't be read (e.g. DB not ready)
|
||||
@@ -124,6 +144,7 @@ Document.getInitialProps = async (
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
faviconLightHref,
|
||||
customCss,
|
||||
},
|
||||
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
||||
@@ -135,6 +156,7 @@ Document.getInitialProps = async (
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
faviconLightHref,
|
||||
customCss,
|
||||
baseUrl,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function ErrorPage({ statusCode }: Props) {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||
const logoLightUrl = whitelabeling?.logoLightUrl || undefined;
|
||||
const errorTitle = whitelabeling?.errorPageTitle;
|
||||
const errorDescription = whitelabeling?.errorPageDescription;
|
||||
|
||||
@@ -22,7 +23,7 @@ export default function ErrorPage({ statusCode }: Props) {
|
||||
<header className="mb-auto flex justify-center z-50 w-full py-4">
|
||||
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo logoUrl={logoUrl} />
|
||||
<Logo logoUrl={logoUrl} logoLightUrl={logoLightUrl} />
|
||||
<span className="font-medium text-sm">{appName}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
@@ -1663,19 +1663,19 @@ const EnvironmentPage = (
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full min-w-0">
|
||||
<div className="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap break-words">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
<span className="text-sm font-medium text-muted-foreground wrap-anywhere line-clamp-3">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||
<span className="text-sm font-medium text-muted-foreground self-start shrink-0">
|
||||
{service.type === "postgres" && (
|
||||
<PostgresqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { Fingerprint } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
@@ -49,6 +48,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
@@ -306,6 +306,11 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
Sign in
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -26,6 +25,7 @@ import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
@@ -164,6 +164,11 @@ const Invitation = ({
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Invitation
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
@@ -29,6 +28,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
@@ -138,6 +138,11 @@ const Register = ({ isCloud }: Props) => {
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
{isCloud ? "Sign Up" : "Setup the server"}
|
||||
|
||||
@@ -106,6 +106,11 @@ export default function Home({ tokenResetPassword }: Props) {
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Reset Password
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { IS_CLOUD } from "@dokploy/server";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { generateServerSideHelper } from "@/utils/create-server-helpers";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const loginSchema = z.object({
|
||||
@@ -92,6 +92,11 @@ export default function Home() {
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
|
||||
}
|
||||
logoLightUrl={
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{whitelabeling?.appName || "Dokploy"}
|
||||
|
||||
@@ -87,6 +87,10 @@ export const whitelabelingRouter = createTRPCRouter({
|
||||
errorPageDescription: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
updateServerById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { findMemberByUserId } from "@dokploy/server/services/permission";
|
||||
import {
|
||||
findMemberByUserId,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
@@ -52,6 +55,22 @@ import {
|
||||
} from "@/server/db/schema";
|
||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
||||
|
||||
// Users without server.read only get what the server pickers render
|
||||
const toServerOptions = async (
|
||||
ctx: Parameters<typeof hasPermission>[0],
|
||||
servers: (typeof server.$inferSelect)[],
|
||||
) => {
|
||||
await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
|
||||
if (await hasPermission(ctx, { server: ["read"] })) {
|
||||
return servers;
|
||||
}
|
||||
return servers.map(({ serverId, name, ipAddress }) => ({
|
||||
serverId,
|
||||
name,
|
||||
ipAddress,
|
||||
}));
|
||||
};
|
||||
|
||||
export const serverRouter = createTRPCRouter({
|
||||
create: withPermission("server", "create")
|
||||
.input(apiCreateServer)
|
||||
@@ -217,7 +236,7 @@ export const serverRouter = createTRPCRouter({
|
||||
|
||||
return servers.length ?? 0;
|
||||
}),
|
||||
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
|
||||
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
|
||||
const result = await db.query.server.findMany({
|
||||
@@ -235,9 +254,12 @@ export const serverRouter = createTRPCRouter({
|
||||
eq(server.serverType, "deploy"),
|
||||
),
|
||||
});
|
||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
||||
return toServerOptions(
|
||||
ctx,
|
||||
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||
);
|
||||
}),
|
||||
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
|
||||
buildServers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
|
||||
const result = await db.query.server.findMany({
|
||||
@@ -255,7 +277,10 @@ export const serverRouter = createTRPCRouter({
|
||||
eq(server.serverType, "build"),
|
||||
),
|
||||
});
|
||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
||||
return toServerOptions(
|
||||
ctx,
|
||||
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||
);
|
||||
}),
|
||||
setup: withPermission("server", "create")
|
||||
.input(apiFindOneServer)
|
||||
|
||||
@@ -3,7 +3,7 @@ module github.com/mauriciogm/dokploy/apps/monitoring
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.52.11
|
||||
github.com/gofiber/fiber/v2 v2.52.14
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
|
||||
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
|
||||
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
|
||||
github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/node-server": "^1.19.15",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"bullmq": "5.67.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"hono": "^4.11.7",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"hono": "^4.13.5",
|
||||
"ioredis": "5.4.1",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
|
||||
+23
-3
@@ -26,7 +26,7 @@
|
||||
"@biomejs/biome": "2.5.7",
|
||||
"@types/node": "^24.4.0",
|
||||
"dotenv": "16.4.5",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"tsx": "4.22.4"
|
||||
},
|
||||
@@ -46,11 +46,31 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"better-call": "1.3.7",
|
||||
"@better-fetch/fetch": "1.3.1",
|
||||
"tar": ">=7.5.19 <8.0.0",
|
||||
"protobufjs": ">=7.5.5 <8.0.0"
|
||||
"protobufjs": ">=7.5.5 <8.0.0",
|
||||
"js-yaml": ">=4.3.2 <5.0.0",
|
||||
"mysql2": ">=3.23.1 <4.0.0",
|
||||
"deepmerge-ts": ">=8.0.0 <9.0.0",
|
||||
"@ai-sdk/provider-utils": ">=4.0.33 <5.0.0",
|
||||
"hono": ">=4.13.5 <5.0.0",
|
||||
"@hono/node-server": ">=1.19.15 <2.0.0",
|
||||
"lodash": "4.18.1",
|
||||
"postcss": ">=8.5.23 <9.0.0",
|
||||
"effect": ">=3.20.0 <4.0.0",
|
||||
"h3": ">=1.15.9 <2.0.0",
|
||||
"tar-fs": ">=2.1.4 <3.0.0",
|
||||
"uuid": ">=11.1.1 <12.0.0",
|
||||
"valibot": ">=1.4.2 <2.0.0",
|
||||
"proxy-addr": ">=2.0.8 <3.0.0",
|
||||
"axios": ">=1.20.0 <2.0.0",
|
||||
"source-map-js": ">=1.2.2 <2.0.0",
|
||||
"ip-address": ">=10.7.1 <11.0.0",
|
||||
"sharp": ">=0.35.5 <0.36.0",
|
||||
"@modelcontextprotocol/sdk": ">=1.31.0 <2.0.0",
|
||||
"fast-copy": ">=3.1.0 <4.0.0"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
|
||||
@@ -47,14 +47,14 @@
|
||||
"@better-auth/scim": "1.6.23",
|
||||
"@better-auth/sso": "1.6.23",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@faker-js/faker": "^10.5.0",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@trpc/server": "11.10.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"adm-zip": "^0.6.1",
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
@@ -67,13 +67,13 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.18",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.1.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"nodemailer": "10.0.15",
|
||||
"octokit": "3.1.2",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
@@ -84,35 +84,32 @@
|
||||
"react-dom": "19.2.7",
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.4",
|
||||
"shell-quote": "^1.11.0",
|
||||
"slugify": "^1.6.6",
|
||||
"ssh2": "~1.16.0",
|
||||
"toml": "3.0.0",
|
||||
"toml": "4.2.0",
|
||||
"undici": "^6.21.3",
|
||||
"ws": "8.16.0",
|
||||
"yaml": "2.8.1",
|
||||
"ws": "8.21.0",
|
||||
"yaml": "2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/dockerode": "3.3.23",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"esbuild-plugin-alias": "0.2.1",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss": "^8.5.23",
|
||||
"rimraf": "6.1.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsc-alias": "1.8.10",
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ovhDnsConfigSchema = z.object({
|
||||
applicationKey: z.string().trim().min(1),
|
||||
applicationSecret: z.string().trim().min(1),
|
||||
consumerKey: z.string().trim().min(1),
|
||||
zone: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
|
||||
@@ -97,6 +97,10 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
errorPageDescription: string | null;
|
||||
footerText: string | null;
|
||||
ogImageUrl: string | null;
|
||||
logoLightUrl: string | null;
|
||||
loginLogoLightUrl: string | null;
|
||||
faviconLightUrl: string | null;
|
||||
dualModeBranding: boolean;
|
||||
}>()
|
||||
.default({
|
||||
appName: null,
|
||||
@@ -111,6 +115,10 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
errorPageDescription: null,
|
||||
footerText: null,
|
||||
ogImageUrl: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
}),
|
||||
// Deployment Configuration (self-hosted only)
|
||||
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||
@@ -240,6 +248,10 @@ export const whitelabelingConfigSchema = z.object({
|
||||
errorPageDescription: z.string().nullable(),
|
||||
footerText: z.string().nullable(),
|
||||
ogImageUrl: safeUrl,
|
||||
logoLightUrl: safeUrl,
|
||||
loginLogoLightUrl: safeUrl,
|
||||
faviconLightUrl: safeUrl,
|
||||
dualModeBranding: z.boolean(),
|
||||
});
|
||||
|
||||
export const apiUpdateWhitelabeling = z.object({
|
||||
|
||||
+8
-8
@@ -476,8 +476,8 @@ packages:
|
||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||
engines: {node: ^4.5.0 || >= 5.9}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
chokidar@4.0.3:
|
||||
@@ -773,8 +773,8 @@ packages:
|
||||
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
||||
engines: {node: '>=10.2.0'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
@@ -1166,7 +1166,7 @@ snapshots:
|
||||
|
||||
base64id@2.0.0: {}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -1200,7 +1200,7 @@ snapshots:
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
@@ -1361,7 +1361,7 @@ snapshots:
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -1490,7 +1490,7 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface PublicWhitelabelingConfig {
|
||||
errorPageTitle: string | null;
|
||||
errorPageDescription: string | null;
|
||||
footerText: string | null;
|
||||
logoLightUrl: string | null;
|
||||
loginLogoLightUrl: string | null;
|
||||
faviconLightUrl: string | null;
|
||||
dualModeBranding: boolean;
|
||||
}
|
||||
|
||||
// Self-hosted is single-tenant; gate on the oldest organization's license,
|
||||
@@ -54,5 +58,15 @@ export const getPublicWhitelabelingConfig =
|
||||
errorPageTitle: config.errorPageTitle,
|
||||
errorPageDescription: config.errorPageDescription,
|
||||
footerText: config.footerText,
|
||||
dualModeBranding: config.dualModeBranding ?? false,
|
||||
logoLightUrl: config.dualModeBranding
|
||||
? (config.logoLightUrl ?? null)
|
||||
: null,
|
||||
loginLogoLightUrl: config.dualModeBranding
|
||||
? (config.loginLogoLightUrl ?? null)
|
||||
: null,
|
||||
faviconLightUrl: config.dualModeBranding
|
||||
? (config.faviconLightUrl ?? null)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -163,7 +163,7 @@ export function processValue(
|
||||
}
|
||||
|
||||
if (varName === "username") {
|
||||
return faker.internet.userName().toLowerCase();
|
||||
return faker.internet.username().toLowerCase();
|
||||
}
|
||||
|
||||
if (varName === "email") {
|
||||
|
||||
@@ -252,13 +252,25 @@ const listZoneNames = async (config: OvhConfig) => {
|
||||
}
|
||||
};
|
||||
|
||||
const assertZoneAllowed = (config: OvhConfig, zoneId: string) => {
|
||||
if (config.zone && zoneId !== config.zone) {
|
||||
throw new Error(
|
||||
`OVH: this provider is restricted to the zone "${config.zone}", not "${zoneId}"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const ovhClient: DnsClient<OvhConfig> = {
|
||||
async listZones(config) {
|
||||
if (config.zone) {
|
||||
return [{ id: config.zone, name: config.zone }];
|
||||
}
|
||||
const zones = await listZoneNames(config);
|
||||
return zones.map((zone) => ({ id: zone, name: zone }));
|
||||
},
|
||||
|
||||
async listRecords(config, zoneId) {
|
||||
assertZoneAllowed(config, zoneId);
|
||||
const zone = encodeURIComponent(zoneId);
|
||||
// The listing endpoint only returns ids, so each record is fetched on its own.
|
||||
const ids = await ovhFetch<number[]>(config, `/domain/zone/${zone}/record`);
|
||||
@@ -276,6 +288,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
|
||||
},
|
||||
|
||||
async upsertRecord(config, record) {
|
||||
assertZoneAllowed(config, record.zoneId);
|
||||
const zone = encodeURIComponent(record.zoneId);
|
||||
const subDomain = toSubDomain(record.name, record.zoneId);
|
||||
const existing = await ovhFetch<number[]>(
|
||||
@@ -319,6 +332,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
|
||||
},
|
||||
|
||||
async updateRecord(config, zoneId, recordId, record) {
|
||||
assertZoneAllowed(config, zoneId);
|
||||
const zone = encodeURIComponent(zoneId);
|
||||
const existing = await ovhFetch<OvhRecord>(
|
||||
config,
|
||||
@@ -363,6 +377,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
|
||||
},
|
||||
|
||||
async deleteRecord(config, zoneId, recordId) {
|
||||
assertZoneAllowed(config, zoneId);
|
||||
await ovhFetch(
|
||||
config,
|
||||
`/domain/zone/${encodeURIComponent(zoneId)}/record/${recordId}`,
|
||||
@@ -372,6 +387,10 @@ export const ovhClient: DnsClient<OvhConfig> = {
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
if (config.zone) {
|
||||
await ovhFetch(config, `/domain/zone/${encodeURIComponent(config.zone)}`);
|
||||
return;
|
||||
}
|
||||
await listZoneNames(config);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const refreshGitlabToken = async (gitlabProviderId: string) => {
|
||||
const refreshGitlabTokenIfExpired = async (gitlabProviderId: string) => {
|
||||
const gitlabProvider = await findGitlabById(gitlabProviderId);
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -56,6 +56,23 @@ export const refreshGitlabToken = async (gitlabProviderId: string) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
// GitLab invalidates a refresh token when it is used, so concurrent callers must share one refresh
|
||||
const pendingRefreshes = new Map<
|
||||
string,
|
||||
ReturnType<typeof refreshGitlabTokenIfExpired>
|
||||
>();
|
||||
|
||||
export const refreshGitlabToken = (gitlabProviderId: string) => {
|
||||
let refresh = pendingRefreshes.get(gitlabProviderId);
|
||||
if (!refresh) {
|
||||
refresh = refreshGitlabTokenIfExpired(gitlabProviderId).finally(() => {
|
||||
pendingRefreshes.delete(gitlabProviderId);
|
||||
});
|
||||
pendingRefreshes.set(gitlabProviderId, refresh);
|
||||
}
|
||||
return refresh;
|
||||
};
|
||||
|
||||
export const haveGitlabRequirements = (gitlabProvider: Gitlab) => {
|
||||
return !!(gitlabProvider?.accessToken && gitlabProvider?.refreshToken);
|
||||
};
|
||||
|
||||
Generated
+1936
-1801
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user