Compare commits

..

2 Commits

Author SHA1 Message Date
Mauricio Siu
0dbd1039e8 feat: enhance auth schema and add CLI configuration
- Updated the user schema to include new fields: banned, banReason, and banExpires for improved user management.
- Introduced a new auth-cli configuration file to facilitate database schema generation and inspection for better-auth plugins.
- Ensured the CLI configuration mirrors the plugin set in auth.ts for consistency across the application.
2026-04-19 12:05:37 -06:00
Mauricio Siu
f06c9deddf feat: implement SCIM provisioning support
- Updated dependencies for better-auth packages to version 1.6.5, including api-key, sso, and utils.
- Introduced a new SCIM dialog component for managing SCIM providers and tokens.
- Added SCIM provider management functionality in the backend, including listing, generating tokens, and deleting providers.
- Created a new database table for SCIM providers and updated the schema accordingly.
- Enhanced authentication logic to support SCIM provisioning with enterprise features validation.
2026-04-19 11:59:56 -06:00
38 changed files with 9733 additions and 1066 deletions

View File

@@ -3,9 +3,6 @@ name: Sync version to MCP and CLI repos
on:
release:
types: [published]
push:
tags:
- 'v*'
workflow_dispatch:
jobs:

View File

@@ -79,11 +79,8 @@ export const columns: ColumnDef<LogEntry>[] = [
: log.RequestPath}
</div>
<div className="flex flex-row gap-3 w-full">
<Badge
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
>
Status:{" "}
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
<Badge variant={getStatusColor(log.OriginStatus)}>
Status: {formatStatusLabel(log.OriginStatus)}
</Badge>
<Badge variant={"secondary"}>
Exec Time: {formatDuration(log.Duration)}

View File

@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by hostname..."
placeholder="Filter by name..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="md:max-w-sm"

View File

@@ -0,0 +1,236 @@
"use client";
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
interface Props {
children: ReactNode;
}
export const ScimDialog = ({ children }: Props) => {
const utils = api.useUtils();
const baseURL = useUrl();
const [open, setOpen] = useState(false);
const [newProviderId, setNewProviderId] = useState("");
const [justCreatedToken, setJustCreatedToken] = useState<{
providerId: string;
token: string;
} | null>(null);
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
undefined,
{ enabled: open },
);
const { mutateAsync: generateToken, isPending: isGenerating } =
api.scim.generateToken.useMutation();
const { mutateAsync: deleteProvider, isPending: isDeleting } =
api.scim.deleteProvider.useMutation();
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
const handleGenerate = async () => {
const providerId = newProviderId.trim().toLowerCase();
if (!providerId) return;
try {
const result = await generateToken({ providerId });
setJustCreatedToken({
providerId: result.providerId,
token: result.scimToken,
});
setNewProviderId("");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to generate SCIM token",
);
}
};
const handleDelete = async (providerId: string) => {
try {
await deleteProvider({ providerId });
toast.success("SCIM provider removed");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to delete SCIM provider",
);
}
};
const handleCopy = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(`${label} copied`);
} catch {
toast.error("Failed to copy");
}
};
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setJustCreatedToken(null);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
SCIM provisioning
</DialogTitle>
<DialogDescription>
Automatically provision, update, and deactivate users from your
identity provider (Okta, Entra ID, etc.). Configure the SCIM endpoint
below in your IdP.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid gap-1">
<Label className="text-xs font-medium text-muted-foreground">
SCIM 2.0 endpoint URL
</Label>
<div className="flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{scimUrl}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
disabled={!baseURL}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
{justCreatedToken && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<p className="text-sm font-medium">
Bearer token for {justCreatedToken.providerId}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Copy this token now it will not be shown again. Paste it into
your IdP's SCIM configuration.
</p>
<div className="mt-2 flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
{justCreatedToken.token}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() =>
handleCopy(justCreatedToken.token, "Bearer token")
}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-medium">
Generate token for a new provider
</Label>
<div className="flex gap-2">
<Input
value={newProviderId}
onChange={(e) => setNewProviderId(e.target.value)}
placeholder="okta, entra, jumpcloud..."
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleGenerate();
}
}}
/>
<Button
size="sm"
onClick={handleGenerate}
disabled={!newProviderId.trim() || isGenerating}
>
<Plus className="mr-1 size-4" />
Generate
</Button>
</div>
<p className="text-xs text-muted-foreground">
Choose a unique identifier for this IdP connection (lowercase,
alphanumeric, dashes).
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Existing providers</Label>
{isPending ? (
<div className="flex items-center gap-2 justify-center py-4">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
) : providers.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
No SCIM providers configured yet.
</p>
) : (
<ul className="flex flex-col gap-2">
{providers.map((provider) => (
<li
key={provider.id}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
>
<span className="flex-1 font-mono text-sm">
{provider.providerId}
</span>
<DialogAction
title="Remove SCIM provider"
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
type="destructive"
onClick={() => handleDelete(provider.providerId)}
>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive hover:text-destructive"
disabled={isDeleting}
>
<Trash2 className="size-3.5" />
</Button>
</DialogAction>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,6 +2,7 @@
import {
Eye,
KeyRound,
Loader2,
LogIn,
Pencil,
@@ -34,6 +35,7 @@ import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
import { ScimDialog } from "./scim-dialog";
type ProviderForDetails = {
id: string | null;
@@ -169,15 +171,22 @@ export const SSOSettings = () => {
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
<div className="flex flex-wrap gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
<ScimDialog>
<Button variant="outline" size="sm">
<KeyRound className="mr-2 size-4" />
Manage SCIM
</Button>
</ScimDialog>
</div>
</div>
{isPending ? (

View File

@@ -0,0 +1,11 @@
CREATE TABLE "scim_provider" (
"id" text PRIMARY KEY NOT NULL,
"provider_id" text NOT NULL,
"scim_token" text NOT NULL,
"organization_id" text,
CONSTRAINT "scim_provider_provider_id_unique" UNIQUE("provider_id"),
CONSTRAINT "scim_provider_scim_token_unique" UNIQUE("scim_token")
);
--> statement-breakpoint
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "scim_provider" ADD CONSTRAINT "scim_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1163,6 +1163,13 @@
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1776576422440,
"tag": "0166_overjoyed_big_bertha",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.2",
"version": "v0.29.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -46,8 +46,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@better-auth/api-key": "1.6.5",
"@better-auth/sso": "1.6.5",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
@@ -101,7 +101,7 @@
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.5.4",
"better-auth": "1.6.5",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.67.3",
@@ -113,7 +113,7 @@
"dockerode": "4.0.2",
"dompurify": "^3.3.3",
"dotenv": "16.4.5",
"drizzle-orm": "0.45.1",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",
@@ -147,7 +147,7 @@
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.31.2",

View File

@@ -12,15 +12,6 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
/**
* Log a webhook handler error server-side without leaking its shape to the HTTP
* response. Drizzle errors carry the raw SQL query, column list and parameters,
* so we never forward the error object to the client.
*/
export const logWebhookError = (context: string, error: unknown) => {
console.error(context, error);
};
/**
* Helper function to get package_version from registry_package events
*/
@@ -271,15 +262,14 @@ export default async function handler(
);
}
} catch (error) {
logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
res.status(400).json({ message: "Error deploying Application", error });
return;
}
res.status(200).json({ message: "Application deployed successfully" });
} catch (error) {
logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
console.log(error);
res.status(400).json({ message: "Error deploying Application", error });
}
}

View File

@@ -12,7 +12,6 @@ import {
extractCommittedPaths,
extractHash,
getProviderByHeader,
logWebhookError,
} from "../[refreshToken]";
export default async function handler(
@@ -196,14 +195,13 @@ export default async function handler(
);
}
} catch (error) {
logWebhookError("Error deploying Compose:", error);
res.status(400).json({ message: "Error deploying Compose" });
res.status(400).json({ message: "Error deploying Compose", error });
return;
}
res.status(200).json({ message: "Compose deployed successfully" });
} catch (error) {
logWebhookError("Error deploying Compose:", error);
res.status(400).json({ message: "Error deploying Compose" });
console.log(error);
res.status(400).json({ message: "Error deploying Compose", error });
}
}

View File

@@ -17,11 +17,7 @@ import { applications, compose, github } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import {
extractCommitMessage,
extractHash,
logWebhookError,
} from "./[refreshToken]";
import { extractCommitMessage, extractHash } from "./[refreshToken]";
export default async function handler(
req: NextApiRequest,
@@ -201,8 +197,10 @@ export default async function handler(
});
return;
} catch (error) {
logWebhookError("Error deploying applications on tag:", error);
res.status(400).json({ message: "Error deploying applications on tag" });
console.error("Error deploying applications on tag:", error);
res
.status(400)
.json({ message: "Error deploying applications on tag", error });
return;
}
}
@@ -324,8 +322,7 @@ export default async function handler(
}
res.status(200).json({ message: `Deployed ${totalApps} apps` });
} catch (error) {
logWebhookError("Error deploying Application:", error);
res.status(400).json({ message: "Error deploying Application" });
res.status(400).json({ message: "Error deploying Application", error });
}
} else if (req.headers["x-github-event"] === "pull_request") {
const prId = githubBody?.pull_request?.id;

View File

@@ -31,6 +31,7 @@ import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { scimRouter } from "./routers/proprietary/scim";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
import { redirectsRouter } from "./routers/redirects";
@@ -93,6 +94,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
scim: scimRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,

View File

@@ -458,26 +458,9 @@ export const backupRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
.query(async ({ input }) => {
try {
const destination = await findDestinationById(input.destinationId);
if (destination.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this destination.",
});
}
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;

View File

@@ -18,16 +18,7 @@ export const clusterRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
.query(async ({ input }) => {
const docker = await getRemoteDocker(input.serverId);
const workers: DockerNode[] = await docker.listNodes();
return workers;
@@ -41,15 +32,6 @@ export const clusterRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
try {
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
const removeCommand = `docker node rm ${input.nodeId} --force`;
@@ -83,16 +65,7 @@ export const clusterRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
.query(async ({ input }) => {
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();
@@ -115,16 +88,7 @@ export const clusterRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
.query(async ({ input }) => {
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();

View File

@@ -16,7 +16,6 @@ import {
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { findServerById } from "@dokploy/server/services/server";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
@@ -53,14 +52,7 @@ export const deploymentRouter = createTRPCRouter({
}),
allByServer: withPermission("deployment", "read")
.input(apiFindAllByServer)
.query(async ({ input, ctx }) => {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
.query(async ({ input }) => {
return await findAllDeploymentsByServerId(input.serverId);
}),
allCentralized: withPermission("deployment", "read").query(

View File

@@ -1,5 +1,5 @@
import { db } from "@dokploy/server/db";
import { IS_CLOUD, sendInvitationEmail } from "@dokploy/server/index";
import { IS_CLOUD } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -325,24 +325,6 @@ export const organizationRouter = createTRPCRouter({
})
.returning();
if (IS_CLOUD && created) {
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.dokploy.com";
const inviteLink = `${host}/invitation?token=${created.id}`;
const org = await db.query.organization.findFirst({
where: eq(organization.id, orgId),
});
await sendInvitationEmail({
email,
inviteLink,
organizationName: org?.name || "organization",
});
}
await audit(ctx, {
action: "create",
resourceType: "organization",

View File

@@ -0,0 +1,78 @@
import { db } from "@dokploy/server/db";
import { scimProvider } from "@dokploy/server/db/schema";
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";
import { z } from "zod";
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
const providerIdSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z0-9][a-z0-9-]*$/,
"Provider ID must be lowercase alphanumeric with optional dashes",
);
export const scimRouter = createTRPCRouter({
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.scimProvider.findMany({
where: eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
columns: {
id: true,
providerId: true,
organizationId: true,
},
orderBy: [asc(scimProvider.providerId)],
});
return providers;
}),
generateToken: enterpriseProcedure
.input(z.object({ providerId: providerIdSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await db.query.scimProvider.findFirst({
where: eq(scimProvider.providerId, input.providerId),
columns: { id: true, organizationId: true },
});
if (existing) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A SCIM provider with this ID already exists",
});
}
const result = await auth.generateSCIMToken({
body: {
providerId: input.providerId,
organizationId: ctx.session.activeOrganizationId,
},
headers: requestToHeaders(ctx.req),
});
return { scimToken: result.scimToken, providerId: input.providerId };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: providerIdSchema }))
.mutation(async ({ ctx, input }) => {
const [deleted] = await db
.delete(scimProvider)
.where(
and(
eq(scimProvider.providerId, input.providerId),
eq(
scimProvider.organizationId,
ctx.session.activeOrganizationId,
),
),
)
.returning({ id: scimProvider.id });
if (!deleted) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SCIM provider not found or you do not have permission to delete it",
});
}
return { success: true };
}),
});

View File

@@ -7,25 +7,19 @@ import {
updateScheduleSchema,
} from "@dokploy/server/db/schema/schedule";
import { runCommand } from "@dokploy/server/index";
import {
checkPermission,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import {
createSchedule,
deleteSchedule,
findScheduleById,
updateSchedule,
} from "@dokploy/server/services/schedule";
import { findServerById } from "@dokploy/server/services/server";
import { TRPCError } from "@trpc/server";
import { asc, desc, eq } from "drizzle-orm";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { removeJob, schedule } from "@/server/utils/backup";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const scheduleRouter = createTRPCRouter({
create: protectedProcedure
.input(createScheduleSchema)
@@ -35,45 +29,6 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
} else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
input.scheduleType === "server" ||
input.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (input.scheduleType === "server" && input.serverId) {
const targetServer = await findServerById(input.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
}
const newSchedule = await createSchedule(input);
@@ -102,77 +57,12 @@ export const scheduleRouter = createTRPCRouter({
.input(updateScheduleSchema)
.mutation(async ({ input, ctx }) => {
const existingSchedule = await findScheduleById(input.scheduleId);
if (
IS_CLOUD &&
input.scheduleType &&
input.scheduleType !== existingSchedule.scheduleType
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Changing scheduleType is not allowed in the cloud version.",
});
}
const serviceId =
existingSchedule.applicationId || existingSchedule.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["update"],
});
} else {
if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["update"] });
if (
existingSchedule.scheduleType === "server" ||
existingSchedule.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (
existingSchedule.scheduleType === "server" &&
existingSchedule.serverId
) {
const targetServer = await findServerById(existingSchedule.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
existingSchedule.scheduleType === "dokploy-server" &&
existingSchedule.userId &&
existingSchedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
const updatedSchedule = await updateSchedule(input);
@@ -217,56 +107,6 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["delete"],
});
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["delete"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
await deleteSchedule(input.scheduleId);
@@ -308,30 +148,6 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, input.id, {
schedule: ["read"],
});
} else {
await checkPermission(ctx, { schedule: ["read"] });
if (input.scheduleType === "server") {
const targetServer = await findServerById(input.id);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
input.scheduleType === "dokploy-server" &&
input.id !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only list your own host-level schedules.",
});
}
}
const where = {
application: eq(schedules.applicationId, input.id),
@@ -362,31 +178,6 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["read"],
});
} else {
await checkPermission(ctx, { schedule: ["read"] });
if (schedule.scheduleType === "server" && schedule.serverId) {
const targetServer = await findServerById(schedule.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
}
if (
schedule.scheduleType === "dokploy-server" &&
schedule.userId &&
schedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
}
return schedule;
}),
@@ -400,56 +191,6 @@ export const scheduleRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
try {
await runCommand(input.scheduleId);

View File

@@ -9,12 +9,12 @@ import {
getWebServerSettings,
IS_CLOUD,
removeUserById,
renderInvitationEmail,
sendEmailNotification,
sendResendNotification,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import {
account,
apiAssignPermissions,
@@ -29,7 +29,6 @@ import {
hasPermission,
resolvePermissions,
} from "@dokploy/server/services/permission";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, asc, eq, gt } from "drizzle-orm";
@@ -640,26 +639,27 @@ export const userRouter = createTRPCRouter({
);
try {
const toEmail = currentInvitation?.email || "";
const orgName = organization?.name || "organization";
const subject = `You've been invited to join ${orgName} on Dokploy`;
const html = await renderInvitationEmail({
email: toEmail,
inviteLink,
organizationName: orgName,
});
const htmlContent = `
\t\t\t\t<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
\t\t\t\t`;
if (email) {
await sendEmailNotification(
{ ...email, toAddresses: [toEmail] },
subject,
html,
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
} else if (resend) {
await sendResendNotification(
{ ...resend, toAddresses: [toEmail] },
subject,
html,
{
...resend,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
htmlContent,
);
}
} catch (error) {

View File

@@ -15,9 +15,7 @@ import {
updateVolumeBackupSchema,
volumeBackups,
} from "@dokploy/server/db/schema";
import { findDestinationById } from "@dokploy/server/services/destination";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { findServerById } from "@dokploy/server/services/server";
import {
execAsyncRemote,
execAsyncStream,
@@ -267,23 +265,7 @@ export const volumeBackupsRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.subscription(async ({ input, ctx }) => {
const destination = await findDestinationById(input.destinationId);
if (destination.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this destination.",
});
}
if (input.serverId) {
const targetServer = await findServerById(input.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
.subscription(async ({ input }) => {
return observable<string>((emit) => {
const runRestore = async () => {
try {

View File

@@ -85,11 +85,6 @@ export const setupDockerContainerLogsWebSocketServer = (
if (serverId) {
const server = await findServerById(serverId);
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
if (!server.sshKeyId) return;
const client = new Client();
client

View File

@@ -61,12 +61,6 @@ export const setupDockerContainerTerminalWebSocketServer = (
try {
if (serverId) {
const server = await findServerById(serverId);
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
if (!server.sshKeyId)
throw new Error("No SSH key available for this server");

View File

@@ -57,11 +57,6 @@ export const setupDeploymentLogsWebSocketServer = (
if (serverId) {
const server = await findServerById(serverId);
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
if (!server.sshKeyId) {
ws.close();
return;

View File

@@ -154,11 +154,6 @@ export const setupTerminalWebSocketServer = (
return;
}
if (server.organizationId !== session.activeOrganizationId) {
ws.close();
return;
}
const { ipAddress: host, port, username, sshKey, sshKeyId } = server;
if (!sshKeyId) {

View File

@@ -1,299 +1,311 @@
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
pgTable,
text,
timestamp,
boolean,
integer,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
firstName: text("first_name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
role: text("role"),
ownerId: text("owner_id"),
allowImpersonation: boolean("allow_impersonation").default(false),
lastName: text("last_name").default(""),
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
id: text("id").primaryKey(),
firstName: text("first_name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
ownerId: text("owner_id"),
allowImpersonation: boolean("allow_impersonation").default(false),
lastName: text("last_name").default(""),
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
impersonatedBy: text("impersonated_by"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
referenceId: text("reference_id").notNull(),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
referenceId: text("reference_id").notNull(),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
);
export const ssoProvider = pgTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
});
export const twoFactor = pgTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
verified: boolean("verified").default(true),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
);
export const organization = pgTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
export const organizationRole = pgTable(
"organization_role",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(
() => /* @__PURE__ */ new Date(),
),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
"organization_role",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(
() => /* @__PURE__ */ new Date(),
),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
);
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
export const scimProvider = pgTable("scim_provider", {
id: text("id").primaryKey(),
providerId: text("provider_id").notNull().unique(),
scimToken: text("scim_token").notNull().unique(),
organizationId: text("organization_id"),
});
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor),
members: many(member),
invitations: many(invitation),
sessions: many(session),
accounts: many(account),
ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor),
members: many(member),
invitations: many(invitation),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
organizationRoles: many(organizationRole),
members: many(member),
invitations: many(invitation),
organizationRoles: many(organizationRole),
members: many(member),
invitations: many(invitation),
}));
export const organizationRoleRelations = relations(
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
);
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));

View File

@@ -37,9 +37,10 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@better-auth/utils": "0.3.1",
"@better-auth/api-key": "1.6.5",
"@better-auth/scim": "^1.6.5",
"@better-auth/sso": "1.6.5",
"@better-auth/utils": "0.4.0",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
@@ -51,15 +52,14 @@
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.5.4",
"better-call": "2.0.2",
"better-auth": "1.6.5",
"bl": "6.0.11",
"boxen": "^7.1.1",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.1",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.5.1",
"lodash": "4.17.21",
"micromatch": "4.0.8",
@@ -80,7 +80,7 @@
"semver": "7.7.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "~1.16.0",
"ssh2": "1.15.0",
"toml": "3.0.0",
"ws": "8.16.0",
"yaml": "2.8.1",

View File

@@ -214,6 +214,7 @@ export const twoFactor = pgTable("two_factor", {
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
verified: boolean("verified").notNull().default(true),
});
export const apikey = pgTable("apikey", {

View File

@@ -30,6 +30,7 @@ export * from "./redis";
export * from "./registry";
export * from "./rollbacks";
export * from "./schedule";
export * from "./scim";
export * from "./security";
export * from "./server";
export * from "./session";

View File

@@ -0,0 +1,22 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { organization } from "./account";
export const scimProvider = pgTable("scim_provider", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
providerId: text("provider_id").notNull().unique(),
scimToken: text("scim_token").notNull().unique(),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "cascade",
}),
});
export const scimProviderRelations = relations(scimProvider, ({ one }) => ({
organization: one(organization, {
fields: [scimProvider.organizationId],
references: [organization.id],
}),
}));

View File

@@ -14,18 +14,21 @@ import {
Text,
} from "@react-email/components";
interface InvitationEmailProps {
export type TemplateProps = {
email: string;
name: string;
};
interface VercelInviteUserEmailProps {
inviteLink: string;
toEmail: string;
organizationName: string;
}
export const InvitationEmail = ({
inviteLink,
toEmail,
organizationName = "an organization",
}: InvitationEmailProps) => {
const previewText = `You've been invited to join ${organizationName} on Dokploy`;
}: VercelInviteUserEmailProps) => {
const previewText = "Join to Dokploy";
return (
<Html>
<Head />
@@ -41,67 +44,50 @@ export const InvitationEmail = ({
},
}}
>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
{/* Header */}
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
src={
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
}
width="100"
height="50"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
{/* Body */}
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
You've been invited to join {organizationName}
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
You have been invited to join{" "}
<strong className="text-[#09090b]">{organizationName}</strong>{" "}
on Dokploy, the platform for deploying your apps to the cloud.
Click the button below to accept the invitation.
</Text>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={inviteLink}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Accept Invitation
</Button>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
If the button above doesn't work, copy and paste the following
link into your browser:
</Text>
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
{inviteLink}
</Text>
</Section>
{/* Footer */}
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Hr className="border border-solid border-[#e4e4e7] my-0 mb-[16px] mx-0 w-full" />
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This invitation was intended for{" "}
<span className="text-[#71717a]">{toEmail}</span>. This invite
was sent from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you were not expecting this invitation, you can safely
ignore this email.
</Text>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Join to <strong>Dokploy</strong>
</Heading>
<Text className="text-black text-[14px] leading-[24px]">
Hello,
</Text>
<Text className="text-black text-[14px] leading-[24px]">
You have been invited to join <strong>Dokploy</strong>, a platform
that helps for deploying your apps to the cloud.
</Text>
<Section className="text-center mt-[32px] mb-[32px]">
<Button
href={inviteLink}
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
>
Join the team 🚀
</Button>
</Section>
<Text className="text-black text-[14px] leading-[24px]">
or copy and paste this URL into your browser:{" "}
<Link href={inviteLink} className="text-blue-600 no-underline">
https://dokploy.com
</Link>
</Text>
<Hr className="border border-solid border-[#eaeaea] my-[26px] mx-0 w-full" />
<Text className="text-[#666666] text-[12px] leading-[24px]">
This invitation was intended for {toEmail}. This invite was sent
from <strong className="text-black">dokploy.com</strong>. If you
were not expecting this invitation, you can ignore this email. If
you are concerned about your account's safety, please reply to
</Text>
</Container>
</Body>
</Tailwind>

View File

@@ -108,7 +108,6 @@ export * from "./utils/notifications/docker-cleanup";
export * from "./utils/notifications/dokploy-restart";
export * from "./utils/notifications/server-threshold";
export * from "./utils/notifications/utils";
export * from "./verification/send-verification-email";
export * from "./utils/process/execAsync";
export * from "./utils/process/spawnAsync";
export * from "./utils/providers/bitbucket";

View File

@@ -0,0 +1,52 @@
import { apiKey } from "@better-auth/api-key";
import { scim } from "@better-auth/scim";
import { sso } from "@better-auth/sso";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, organization, twoFactor } from "better-auth/plugins";
import { db } from "../db";
import * as schema from "../db/schema";
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
/**
* Minimal better-auth config used only by `@better-auth/cli` to generate /
* inspect database schemas. Must mirror the plugin set in `auth.ts` so the CLI
* sees every table each plugin expects.
*
* Do NOT import this file from the runtime — use `auth.ts` for that.
*/
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
user: {
modelName: "user",
fields: {
name: "firstName",
},
additionalFields: {
role: { type: "string", input: false },
ownerId: { type: "string", input: false },
allowImpersonation: { type: "boolean", defaultValue: false },
lastName: { type: "string", required: false, defaultValue: "" },
enableEnterpriseFeatures: { type: "boolean", required: false },
isValidEnterpriseLicense: { type: "boolean", required: false },
},
},
plugins: [
apiKey({ enableMetadata: true, references: "user" }),
sso(),
twoFactor(),
organization({
ac,
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
dynamicAccessControl: {
enabled: true,
maximumRolesPerOrganization: 10,
},
}),
scim(),
admin(),
],
});

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http";
import { apiKey } from "@better-auth/api-key";
import { scim } from "@better-auth/scim";
import { sso } from "@better-auth/sso";
import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
@@ -178,7 +179,8 @@ const { handler, api } = betterAuth({
}
} else {
const isSSORequest = context?.path.includes("/sso");
if (isSSORequest) {
const isSCIMRequest = context?.path.includes("/scim");
if (isSSORequest || isSCIMRequest) {
return;
}
const isAdminPresent = await db.query.member.findFirst({
@@ -194,6 +196,7 @@ const { handler, api } = betterAuth({
},
after: async (user, context) => {
const isSSORequest = context?.path.includes("/sso");
const isSCIMRequest = context?.path.includes("/scim");
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
@@ -229,6 +232,10 @@ const { handler, api } = betterAuth({
}
}
if (isSCIMRequest) {
return;
}
if (IS_CLOUD || !isAdminPresent) {
await db.transaction(async (tx) => {
const organization = await tx
@@ -396,7 +403,24 @@ const { handler, api } = betterAuth({
enableMetadata: true,
references: "user",
}),
sso(),
sso({
saml: {
enableInResponseToValidation: false,
},
}),
scim({
beforeSCIMTokenGenerated: async ({ user }) => {
const dbUser = await db.query.user.findFirst({
where: eq(schema.user.id, user.id),
columns: { enableEnterpriseFeatures: true },
});
if (!dbUser?.enableEnterpriseFeatures) {
throw new APIError("FORBIDDEN", {
message: "SCIM provisioning requires an enterprise license",
});
}
},
}),
twoFactor(),
organization({
ac,
@@ -409,6 +433,23 @@ const { handler, api } = betterAuth({
enabled: true,
maximumRolesPerOrganization: 10,
},
async sendInvitationEmail(data, _request) {
if (IS_CLOUD) {
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: "https://app.dokploy.com";
const inviteLink = `${host}/invitation?token=${data.id}`;
await sendEmail({
email: data.email,
subject: "Invitation to join organization",
text: `
<p>You are invited to join ${data.organization.name} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
});
}
},
}),
...(IS_CLOUD
? [
@@ -425,6 +466,9 @@ const _auth = {
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
generateSCIMToken: api.generateSCIMToken,
listSCIMProviderConnections: api.listSCIMProviderConnections,
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
};
export type AuthType = typeof _auth;
@@ -464,10 +508,8 @@ export const validateRequest = async (request: IncomingMessage) => {
};
}
const organizationId = (
JSON.parse(apiKeyRecord.metadata || "{}") as {
organizationId?: string;
}
const organizationId = JSON.parse(
apiKeyRecord.metadata || "{}",
).organizationId;
if (!organizationId) {

View File

@@ -30,9 +30,13 @@ export const findPreviewDeploymentById = async (
with: {
domain: true,
application: {
columns: {
applicationId: true,
serverId: true,
with: {
server: true,
environment: {
with: {
project: true,
},
},
},
},
},

View File

@@ -120,7 +120,7 @@ export function parseRawConfig(
if (search) {
parsedLogs = parsedLogs.filter((log) =>
log.RequestHost.toLowerCase().includes(search.toLowerCase()),
log.RequestPath.toLowerCase().includes(search.toLowerCase()),
);
}

View File

@@ -1,5 +1,4 @@
import { renderAsync } from "@react-email/components";
import InvitationEmail from "../emails/emails/invitation";
import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils";
@@ -52,42 +51,3 @@ export const sendVerificationEmail = async ({
text: html,
});
};
export const renderInvitationEmail = async ({
email,
inviteLink,
organizationName,
}: {
email: string;
inviteLink: string;
organizationName: string;
}) => {
return renderAsync(
InvitationEmail({
inviteLink,
toEmail: email,
organizationName,
}),
);
};
export const sendInvitationEmail = async ({
email,
inviteLink,
organizationName,
}: {
email: string;
inviteLink: string;
organizationName: string;
}) => {
const html = await renderInvitationEmail({
email,
inviteLink,
organizationName,
});
await sendEmail({
email,
subject: `You've been invited to join ${organizationName} on Dokploy`,
text: html,
});
};

778
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff