mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-18 12:25:25 +02:00
Compare commits
23 Commits
feat/scim-
...
v0.29.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb6b06f064 | ||
|
|
09824facf8 | ||
|
|
bd46eaec5c | ||
|
|
e9fdc19b96 | ||
|
|
3e81cdac4d | ||
|
|
e72c51444c | ||
|
|
940d18ad25 | ||
|
|
c41b69c925 | ||
|
|
b610f7aeff | ||
|
|
cdd77a04dc | ||
|
|
05f22edfe5 | ||
|
|
29480cde90 | ||
|
|
232ccc9139 | ||
|
|
018e2b153e | ||
|
|
f8c6c8f7cc | ||
|
|
d7af82731c | ||
|
|
c3fa638a56 | ||
|
|
98a586478e | ||
|
|
13248c8d8a | ||
|
|
54417ca8e7 | ||
|
|
598fae0e92 | ||
|
|
eafbd0353e | ||
|
|
91ebf3b6f5 |
3
.github/workflows/sync-version.yml
vendored
3
.github/workflows/sync-version.yml
vendored
@@ -3,6 +3,9 @@ name: Sync version to MCP and CLI repos
|
|||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -79,8 +79,11 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||||||
: log.RequestPath}
|
: log.RequestPath}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-3 w-full">
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<Badge variant={getStatusColor(log.OriginStatus)}>
|
<Badge
|
||||||
Status: {formatStatusLabel(log.OriginStatus)}
|
variant={getStatusColor(log.OriginStatus || log.DownstreamStatus)}
|
||||||
|
>
|
||||||
|
Status:{" "}
|
||||||
|
{formatStatusLabel(log.OriginStatus || log.DownstreamStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={"secondary"}>
|
<Badge variant={"secondary"}>
|
||||||
Exec Time: {formatDuration(log.Duration)}
|
Exec Time: {formatDuration(log.Duration)}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter by name..."
|
placeholder="Filter by hostname..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
className="md:max-w-sm"
|
className="md:max-w-sm"
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
KeyRound,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
LogIn,
|
LogIn,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -35,7 +34,6 @@ import { api } from "@/utils/api";
|
|||||||
import { useUrl } from "@/utils/hooks/use-url";
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||||
import { ScimDialog } from "./scim-dialog";
|
|
||||||
|
|
||||||
type ProviderForDetails = {
|
type ProviderForDetails = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -171,22 +169,15 @@ export const SSOSettings = () => {
|
|||||||
Users can sign in with their organization's IdP.
|
Users can sign in with their organization's IdP.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 shrink-0">
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setManageOriginsOpen(true)}
|
||||||
onClick={() => setManageOriginsOpen(true)}
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<Shield className="mr-2 size-4" />
|
<Shield className="mr-2 size-4" />
|
||||||
Manage origins
|
Manage origins
|
||||||
</Button>
|
</Button>
|
||||||
<ScimDialog>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<KeyRound className="mr-2 size-4" />
|
|
||||||
Manage SCIM
|
|
||||||
</Button>
|
|
||||||
</ScimDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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
@@ -1163,13 +1163,6 @@
|
|||||||
"when": 1775845419261,
|
"when": 1775845419261,
|
||||||
"tag": "0165_abnormal_greymalkin",
|
"tag": "0165_abnormal_greymalkin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 166,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1776576422440,
|
|
||||||
"tag": "0166_overjoyed_big_bertha",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.29.0",
|
"version": "v0.29.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@better-auth/api-key": "1.6.5",
|
"@better-auth/api-key": "1.5.4",
|
||||||
"@better-auth/sso": "1.6.5",
|
"@better-auth/sso": "1.5.4",
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.6.5",
|
"better-auth": "1.5.4",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"bullmq": "5.67.3",
|
"bullmq": "5.67.3",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
"dockerode": "4.0.2",
|
"dockerode": "4.0.2",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.1",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"shell-quote": "^1.8.1",
|
"shell-quote": "^1.8.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "~1.16.0",
|
||||||
"stripe": "17.2.0",
|
"stripe": "17.2.0",
|
||||||
"superjson": "^2.2.2",
|
"superjson": "^2.2.2",
|
||||||
"swagger-ui-react": "^5.31.2",
|
"swagger-ui-react": "^5.31.2",
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
|
|||||||
import { myQueue } from "@/server/queues/queueSetup";
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
import { deploy } from "@/server/utils/deploy";
|
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
|
* Helper function to get package_version from registry_package events
|
||||||
*/
|
*/
|
||||||
@@ -262,14 +271,15 @@ export default async function handler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error deploying Application", error });
|
logWebhookError("Error deploying Application:", error);
|
||||||
|
res.status(400).json({ message: "Error deploying Application" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ message: "Application deployed successfully" });
|
res.status(200).json({ message: "Application deployed successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
logWebhookError("Error deploying Application:", error);
|
||||||
res.status(400).json({ message: "Error deploying Application", error });
|
res.status(400).json({ message: "Error deploying Application" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
extractCommittedPaths,
|
extractCommittedPaths,
|
||||||
extractHash,
|
extractHash,
|
||||||
getProviderByHeader,
|
getProviderByHeader,
|
||||||
|
logWebhookError,
|
||||||
} from "../[refreshToken]";
|
} from "../[refreshToken]";
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
@@ -195,13 +196,14 @@ export default async function handler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error deploying Compose", error });
|
logWebhookError("Error deploying Compose:", error);
|
||||||
|
res.status(400).json({ message: "Error deploying Compose" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ message: "Compose deployed successfully" });
|
res.status(200).json({ message: "Compose deployed successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
logWebhookError("Error deploying Compose:", error);
|
||||||
res.status(400).json({ message: "Error deploying Compose", error });
|
res.status(400).json({ message: "Error deploying Compose" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { applications, compose, github } from "@/server/db/schema";
|
|||||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
import { myQueue } from "@/server/queues/queueSetup";
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
import { deploy } from "@/server/utils/deploy";
|
import { deploy } from "@/server/utils/deploy";
|
||||||
import { extractCommitMessage, extractHash } from "./[refreshToken]";
|
import {
|
||||||
|
extractCommitMessage,
|
||||||
|
extractHash,
|
||||||
|
logWebhookError,
|
||||||
|
} from "./[refreshToken]";
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
@@ -197,10 +201,8 @@ export default async function handler(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deploying applications on tag:", error);
|
logWebhookError("Error deploying applications on tag:", error);
|
||||||
res
|
res.status(400).json({ message: "Error deploying applications on tag" });
|
||||||
.status(400)
|
|
||||||
.json({ message: "Error deploying applications on tag", error });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,7 +324,8 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
res.status(200).json({ message: `Deployed ${totalApps} apps` });
|
res.status(200).json({ message: `Deployed ${totalApps} apps` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error deploying Application", error });
|
logWebhookError("Error deploying Application:", error);
|
||||||
|
res.status(400).json({ message: "Error deploying Application" });
|
||||||
}
|
}
|
||||||
} else if (req.headers["x-github-event"] === "pull_request") {
|
} else if (req.headers["x-github-event"] === "pull_request") {
|
||||||
const prId = githubBody?.pull_request?.id;
|
const prId = githubBody?.pull_request?.id;
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { projectRouter } from "./routers/project";
|
|||||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
import { scimRouter } from "./routers/proprietary/scim";
|
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||||
import { redirectsRouter } from "./routers/redirects";
|
import { redirectsRouter } from "./routers/redirects";
|
||||||
@@ -94,7 +93,6 @@ export const appRouter = createTRPCRouter({
|
|||||||
organization: organizationRouter,
|
organization: organizationRouter,
|
||||||
licenseKey: licenseKeyRouter,
|
licenseKey: licenseKeyRouter,
|
||||||
sso: ssoRouter,
|
sso: ssoRouter,
|
||||||
scim: scimRouter,
|
|
||||||
whitelabeling: whitelabelingRouter,
|
whitelabeling: whitelabelingRouter,
|
||||||
customRole: customRoleRouter,
|
customRole: customRoleRouter,
|
||||||
auditLog: auditLogRouter,
|
auditLog: auditLogRouter,
|
||||||
|
|||||||
@@ -458,9 +458,26 @@ export const backupRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const destination = await findDestinationById(input.destinationId);
|
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 rcloneFlags = getS3Credentials(destination);
|
||||||
const bucketPath = `:s3:${destination.bucket}`;
|
const bucketPath = `:s3:${destination.bucket}`;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,16 @@ export const clusterRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const docker = await getRemoteDocker(input.serverId);
|
const docker = await getRemoteDocker(input.serverId);
|
||||||
const workers: DockerNode[] = await docker.listNodes();
|
const workers: DockerNode[] = await docker.listNodes();
|
||||||
return workers;
|
return workers;
|
||||||
@@ -32,6 +41,15 @@ export const clusterRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.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 {
|
try {
|
||||||
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
|
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
|
||||||
const removeCommand = `docker node rm ${input.nodeId} --force`;
|
const removeCommand = `docker node rm ${input.nodeId} --force`;
|
||||||
@@ -65,7 +83,16 @@ export const clusterRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const docker = await getRemoteDocker(input.serverId);
|
const docker = await getRemoteDocker(input.serverId);
|
||||||
const result = await docker.swarmInspect();
|
const result = await docker.swarmInspect();
|
||||||
const docker_version = await docker.version();
|
const docker_version = await docker.version();
|
||||||
@@ -88,7 +115,16 @@ export const clusterRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const docker = await getRemoteDocker(input.serverId);
|
const docker = await getRemoteDocker(input.serverId);
|
||||||
const result = await docker.swarmInspect();
|
const result = await docker.swarmInspect();
|
||||||
const docker_version = await docker.version();
|
const docker_version = await docker.version();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
checkServicePermissionAndAccess,
|
checkServicePermissionAndAccess,
|
||||||
findMemberByUserId,
|
findMemberByUserId,
|
||||||
} from "@dokploy/server/services/permission";
|
} from "@dokploy/server/services/permission";
|
||||||
|
import { findServerById } from "@dokploy/server/services/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -52,7 +53,14 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
allByServer: withPermission("deployment", "read")
|
allByServer: withPermission("deployment", "read")
|
||||||
.input(apiFindAllByServer)
|
.input(apiFindAllByServer)
|
||||||
.query(async ({ input }) => {
|
.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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
return await findAllDeploymentsByServerId(input.serverId);
|
return await findAllDeploymentsByServerId(input.serverId);
|
||||||
}),
|
}),
|
||||||
allCentralized: withPermission("deployment", "read").query(
|
allCentralized: withPermission("deployment", "read").query(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { IS_CLOUD } from "@dokploy/server/index";
|
import { IS_CLOUD, sendInvitationEmail } from "@dokploy/server/index";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq, exists } from "drizzle-orm";
|
import { and, desc, eq, exists } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -325,6 +325,24 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.returning();
|
.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, {
|
await audit(ctx, {
|
||||||
action: "create",
|
action: "create",
|
||||||
resourceType: "organization",
|
resourceType: "organization",
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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 };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -7,19 +7,25 @@ import {
|
|||||||
updateScheduleSchema,
|
updateScheduleSchema,
|
||||||
} from "@dokploy/server/db/schema/schedule";
|
} from "@dokploy/server/db/schema/schedule";
|
||||||
import { runCommand } from "@dokploy/server/index";
|
import { runCommand } from "@dokploy/server/index";
|
||||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
import {
|
||||||
|
checkPermission,
|
||||||
|
checkServicePermissionAndAccess,
|
||||||
|
findMemberByUserId,
|
||||||
|
} from "@dokploy/server/services/permission";
|
||||||
import {
|
import {
|
||||||
createSchedule,
|
createSchedule,
|
||||||
deleteSchedule,
|
deleteSchedule,
|
||||||
findScheduleById,
|
findScheduleById,
|
||||||
updateSchedule,
|
updateSchedule,
|
||||||
} from "@dokploy/server/services/schedule";
|
} from "@dokploy/server/services/schedule";
|
||||||
|
import { findServerById } from "@dokploy/server/services/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { asc, desc, eq } from "drizzle-orm";
|
import { asc, desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
import { removeJob, schedule } from "@/server/utils/backup";
|
import { removeJob, schedule } from "@/server/utils/backup";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const scheduleRouter = createTRPCRouter({
|
export const scheduleRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createScheduleSchema)
|
.input(createScheduleSchema)
|
||||||
@@ -29,6 +35,45 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["create"],
|
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);
|
const newSchedule = await createSchedule(input);
|
||||||
|
|
||||||
@@ -57,12 +102,77 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
.input(updateScheduleSchema)
|
.input(updateScheduleSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const existingSchedule = await findScheduleById(input.scheduleId);
|
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 =
|
const serviceId =
|
||||||
existingSchedule.applicationId || existingSchedule.composeId;
|
existingSchedule.applicationId || existingSchedule.composeId;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["update"],
|
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);
|
const updatedSchedule = await updateSchedule(input);
|
||||||
|
|
||||||
@@ -107,6 +217,56 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["delete"],
|
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);
|
await deleteSchedule(input.scheduleId);
|
||||||
|
|
||||||
@@ -148,6 +308,30 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||||
schedule: ["read"],
|
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 = {
|
const where = {
|
||||||
application: eq(schedules.applicationId, input.id),
|
application: eq(schedules.applicationId, input.id),
|
||||||
@@ -178,6 +362,31 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["read"],
|
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;
|
return schedule;
|
||||||
}),
|
}),
|
||||||
@@ -191,6 +400,56 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["create"],
|
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 {
|
try {
|
||||||
await runCommand(input.scheduleId);
|
await runCommand(input.scheduleId);
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
getWebServerSettings,
|
getWebServerSettings,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeUserById,
|
removeUserById,
|
||||||
|
renderInvitationEmail,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
sendResendNotification,
|
sendResendNotification,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
|
||||||
import {
|
import {
|
||||||
account,
|
account,
|
||||||
apiAssignPermissions,
|
apiAssignPermissions,
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
hasPermission,
|
hasPermission,
|
||||||
resolvePermissions,
|
resolvePermissions,
|
||||||
} from "@dokploy/server/services/permission";
|
} from "@dokploy/server/services/permission";
|
||||||
|
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import * as bcrypt from "bcrypt";
|
import * as bcrypt from "bcrypt";
|
||||||
import { and, asc, eq, gt } from "drizzle-orm";
|
import { and, asc, eq, gt } from "drizzle-orm";
|
||||||
@@ -639,27 +640,26 @@ export const userRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const htmlContent = `
|
const toEmail = currentInvitation?.email || "";
|
||||||
\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>
|
const orgName = organization?.name || "organization";
|
||||||
\t\t\t\t`;
|
const subject = `You've been invited to join ${orgName} on Dokploy`;
|
||||||
|
const html = await renderInvitationEmail({
|
||||||
|
email: toEmail,
|
||||||
|
inviteLink,
|
||||||
|
organizationName: orgName,
|
||||||
|
});
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
await sendEmailNotification(
|
await sendEmailNotification(
|
||||||
{
|
{ ...email, toAddresses: [toEmail] },
|
||||||
...email,
|
subject,
|
||||||
toAddresses: [currentInvitation?.email || ""],
|
html,
|
||||||
},
|
|
||||||
"Invitation to join organization",
|
|
||||||
htmlContent,
|
|
||||||
);
|
);
|
||||||
} else if (resend) {
|
} else if (resend) {
|
||||||
await sendResendNotification(
|
await sendResendNotification(
|
||||||
{
|
{ ...resend, toAddresses: [toEmail] },
|
||||||
...resend,
|
subject,
|
||||||
toAddresses: [currentInvitation?.email || ""],
|
html,
|
||||||
},
|
|
||||||
"Invitation to join organization",
|
|
||||||
htmlContent,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
updateVolumeBackupSchema,
|
updateVolumeBackupSchema,
|
||||||
volumeBackups,
|
volumeBackups,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
|
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||||
|
import { findServerById } from "@dokploy/server/services/server";
|
||||||
import {
|
import {
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
execAsyncStream,
|
execAsyncStream,
|
||||||
@@ -265,7 +267,23 @@ export const volumeBackupsRouter = createTRPCRouter({
|
|||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscription(async ({ input }) => {
|
.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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return observable<string>((emit) => {
|
return observable<string>((emit) => {
|
||||||
const runRestore = async () => {
|
const runRestore = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
if (serverId) {
|
if (serverId) {
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
|
if (server.organizationId !== session.activeOrganizationId) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!server.sshKeyId) return;
|
if (!server.sshKeyId) return;
|
||||||
const client = new Client();
|
const client = new Client();
|
||||||
client
|
client
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
try {
|
try {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
|
if (server.organizationId !== session.activeOrganizationId) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!server.sshKeyId)
|
if (!server.sshKeyId)
|
||||||
throw new Error("No SSH key available for this server");
|
throw new Error("No SSH key available for this server");
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
if (serverId) {
|
if (serverId) {
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
|
if (server.organizationId !== session.activeOrganizationId) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!server.sshKeyId) {
|
if (!server.sshKeyId) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ export const setupTerminalWebSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (server.organizationId !== session.activeOrganizationId) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { ipAddress: host, port, username, sshKey, sshKeyId } = server;
|
const { ipAddress: host, port, username, sshKey, sshKeyId } = server;
|
||||||
|
|
||||||
if (!sshKeyId) {
|
if (!sshKeyId) {
|
||||||
|
|||||||
@@ -1,311 +1,299 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
pgTable,
|
boolean,
|
||||||
text,
|
index,
|
||||||
timestamp,
|
integer,
|
||||||
boolean,
|
pgTable,
|
||||||
integer,
|
text,
|
||||||
index,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
firstName: text("first_name").notNull(),
|
firstName: text("first_name").notNull(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||||
role: text("role"),
|
role: text("role"),
|
||||||
banned: boolean("banned").default(false),
|
ownerId: text("owner_id"),
|
||||||
banReason: text("ban_reason"),
|
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||||
banExpires: timestamp("ban_expires"),
|
lastName: text("last_name").default(""),
|
||||||
ownerId: text("owner_id"),
|
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
||||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
||||||
lastName: text("last_name").default(""),
|
|
||||||
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
|
||||||
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable(
|
export const session = pgTable(
|
||||||
"session",
|
"session",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
ipAddress: text("ip_address"),
|
ipAddress: text("ip_address"),
|
||||||
userAgent: text("user_agent"),
|
userAgent: text("user_agent"),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
activeOrganizationId: text("active_organization_id"),
|
activeOrganizationId: text("active_organization_id"),
|
||||||
impersonatedBy: text("impersonated_by"),
|
},
|
||||||
},
|
(table) => [index("session_userId_idx").on(table.userId)],
|
||||||
(table) => [index("session_userId_idx").on(table.userId)],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const account = pgTable(
|
export const account = pgTable(
|
||||||
"account",
|
"account",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
accountId: text("account_id").notNull(),
|
accountId: text("account_id").notNull(),
|
||||||
providerId: text("provider_id").notNull(),
|
providerId: text("provider_id").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
accessToken: text("access_token"),
|
accessToken: text("access_token"),
|
||||||
refreshToken: text("refresh_token"),
|
refreshToken: text("refresh_token"),
|
||||||
idToken: text("id_token"),
|
idToken: text("id_token"),
|
||||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
scope: text("scope"),
|
scope: text("scope"),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("account_userId_idx").on(table.userId)],
|
(table) => [index("account_userId_idx").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const verification = pgTable(
|
export const verification = pgTable(
|
||||||
"verification",
|
"verification",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
identifier: text("identifier").notNull(),
|
identifier: text("identifier").notNull(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const apikey = pgTable(
|
export const apikey = pgTable(
|
||||||
"apikey",
|
"apikey",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
configId: text("config_id").default("default").notNull(),
|
configId: text("config_id").default("default").notNull(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
start: text("start"),
|
start: text("start"),
|
||||||
referenceId: text("reference_id").notNull(),
|
referenceId: text("reference_id").notNull(),
|
||||||
prefix: text("prefix"),
|
prefix: text("prefix"),
|
||||||
key: text("key").notNull(),
|
key: text("key").notNull(),
|
||||||
refillInterval: integer("refill_interval"),
|
refillInterval: integer("refill_interval"),
|
||||||
refillAmount: integer("refill_amount"),
|
refillAmount: integer("refill_amount"),
|
||||||
lastRefillAt: timestamp("last_refill_at"),
|
lastRefillAt: timestamp("last_refill_at"),
|
||||||
enabled: boolean("enabled").default(true),
|
enabled: boolean("enabled").default(true),
|
||||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||||
rateLimitMax: integer("rate_limit_max").default(10),
|
rateLimitMax: integer("rate_limit_max").default(10),
|
||||||
requestCount: integer("request_count").default(0),
|
requestCount: integer("request_count").default(0),
|
||||||
remaining: integer("remaining"),
|
remaining: integer("remaining"),
|
||||||
lastRequest: timestamp("last_request"),
|
lastRequest: timestamp("last_request"),
|
||||||
expiresAt: timestamp("expires_at"),
|
expiresAt: timestamp("expires_at"),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
updatedAt: timestamp("updated_at").notNull(),
|
updatedAt: timestamp("updated_at").notNull(),
|
||||||
permissions: text("permissions"),
|
permissions: text("permissions"),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("apikey_configId_idx").on(table.configId),
|
index("apikey_configId_idx").on(table.configId),
|
||||||
index("apikey_referenceId_idx").on(table.referenceId),
|
index("apikey_referenceId_idx").on(table.referenceId),
|
||||||
index("apikey_key_idx").on(table.key),
|
index("apikey_key_idx").on(table.key),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ssoProvider = pgTable("sso_provider", {
|
export const ssoProvider = pgTable("sso_provider", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
issuer: text("issuer").notNull(),
|
issuer: text("issuer").notNull(),
|
||||||
oidcConfig: text("oidc_config"),
|
oidcConfig: text("oidc_config"),
|
||||||
samlConfig: text("saml_config"),
|
samlConfig: text("saml_config"),
|
||||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||||
providerId: text("provider_id").notNull().unique(),
|
providerId: text("provider_id").notNull().unique(),
|
||||||
organizationId: text("organization_id"),
|
organizationId: text("organization_id"),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const twoFactor = pgTable(
|
export const twoFactor = pgTable(
|
||||||
"two_factor",
|
"two_factor",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
secret: text("secret").notNull(),
|
secret: text("secret").notNull(),
|
||||||
backupCodes: text("backup_codes").notNull(),
|
backupCodes: text("backup_codes").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
verified: boolean("verified").default(true),
|
},
|
||||||
},
|
(table) => [
|
||||||
(table) => [
|
index("twoFactor_secret_idx").on(table.secret),
|
||||||
index("twoFactor_secret_idx").on(table.secret),
|
index("twoFactor_userId_idx").on(table.userId),
|
||||||
index("twoFactor_userId_idx").on(table.userId),
|
],
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const organization = pgTable(
|
export const organization = pgTable(
|
||||||
"organization",
|
"organization",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
slug: text("slug").notNull().unique(),
|
slug: text("slug").notNull().unique(),
|
||||||
logo: text("logo"),
|
logo: text("logo"),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const organizationRole = pgTable(
|
export const organizationRole = pgTable(
|
||||||
"organization_role",
|
"organization_role",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
role: text("role").notNull(),
|
role: text("role").notNull(),
|
||||||
permission: text("permission").notNull(),
|
permission: text("permission").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").$onUpdate(
|
updatedAt: timestamp("updated_at").$onUpdate(
|
||||||
() => /* @__PURE__ */ new Date(),
|
() => /* @__PURE__ */ new Date(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||||
index("organizationRole_role_idx").on(table.role),
|
index("organizationRole_role_idx").on(table.role),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const member = pgTable(
|
export const member = pgTable(
|
||||||
"member",
|
"member",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
role: text("role").default("member").notNull(),
|
role: text("role").default("member").notNull(),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("member_organizationId_idx").on(table.organizationId),
|
index("member_organizationId_idx").on(table.organizationId),
|
||||||
index("member_userId_idx").on(table.userId),
|
index("member_userId_idx").on(table.userId),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const invitation = pgTable(
|
export const invitation = pgTable(
|
||||||
"invitation",
|
"invitation",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
email: text("email").notNull(),
|
email: text("email").notNull(),
|
||||||
role: text("role"),
|
role: text("role"),
|
||||||
status: text("status").default("pending").notNull(),
|
status: text("status").default("pending").notNull(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
inviterId: text("inviter_id")
|
inviterId: text("inviter_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("invitation_organizationId_idx").on(table.organizationId),
|
index("invitation_organizationId_idx").on(table.organizationId),
|
||||||
index("invitation_email_idx").on(table.email),
|
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 }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
ssoProviders: many(ssoProvider),
|
ssoProviders: many(ssoProvider),
|
||||||
twoFactors: many(twoFactor),
|
twoFactors: many(twoFactor),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({ one }) => ({
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [session.userId],
|
fields: [session.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [account.userId],
|
fields: [account.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [ssoProvider.userId],
|
fields: [ssoProvider.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [twoFactor.userId],
|
fields: [twoFactor.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||||
organizationRoles: many(organizationRole),
|
organizationRoles: many(organizationRole),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const organizationRoleRelations = relations(
|
export const organizationRoleRelations = relations(
|
||||||
organizationRole,
|
organizationRole,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [organizationRole.organizationId],
|
fields: [organizationRole.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const memberRelations = relations(member, ({ one }) => ({
|
export const memberRelations = relations(member, ({ one }) => ({
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [member.organizationId],
|
fields: [member.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [member.userId],
|
fields: [member.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||||
organization: one(organization, {
|
organization: one(organization, {
|
||||||
fields: [invitation.organizationId],
|
fields: [invitation.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [invitation.inviterId],
|
fields: [invitation.inviterId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -37,10 +37,9 @@
|
|||||||
"@ai-sdk/mistral": "^3.0.20",
|
"@ai-sdk/mistral": "^3.0.20",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@better-auth/api-key": "1.6.5",
|
"@better-auth/api-key": "1.5.4",
|
||||||
"@better-auth/scim": "^1.6.5",
|
"@better-auth/sso": "1.5.4",
|
||||||
"@better-auth/sso": "1.6.5",
|
"@better-auth/utils": "0.3.1",
|
||||||
"@better-auth/utils": "0.4.0",
|
|
||||||
"@faker-js/faker": "^8.4.1",
|
"@faker-js/faker": "^8.4.1",
|
||||||
"@octokit/auth-app": "^6.1.3",
|
"@octokit/auth-app": "^6.1.3",
|
||||||
"@octokit/rest": "^20.1.2",
|
"@octokit/rest": "^20.1.2",
|
||||||
@@ -52,14 +51,15 @@
|
|||||||
"ai": "^6.0.86",
|
"ai": "^6.0.86",
|
||||||
"ai-sdk-ollama": "^3.7.0",
|
"ai-sdk-ollama": "^3.7.0",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.6.5",
|
"better-auth": "1.5.4",
|
||||||
|
"better-call": "2.0.2",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"date-fns": "3.6.0",
|
"date-fns": "3.6.0",
|
||||||
"dockerode": "4.0.2",
|
"dockerode": "4.0.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.1",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"semver": "7.7.3",
|
"semver": "7.7.3",
|
||||||
"shell-quote": "^1.8.1",
|
"shell-quote": "^1.8.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "~1.16.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ export const twoFactor = pgTable("two_factor", {
|
|||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
verified: boolean("verified").notNull().default(true),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apikey = pgTable("apikey", {
|
export const apikey = pgTable("apikey", {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export * from "./redis";
|
|||||||
export * from "./registry";
|
export * from "./registry";
|
||||||
export * from "./rollbacks";
|
export * from "./rollbacks";
|
||||||
export * from "./schedule";
|
export * from "./schedule";
|
||||||
export * from "./scim";
|
|
||||||
export * from "./security";
|
export * from "./security";
|
||||||
export * from "./server";
|
export * from "./server";
|
||||||
export * from "./session";
|
export * from "./session";
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@@ -14,21 +14,18 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from "@react-email/components";
|
} from "@react-email/components";
|
||||||
|
|
||||||
export type TemplateProps = {
|
interface InvitationEmailProps {
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface VercelInviteUserEmailProps {
|
|
||||||
inviteLink: string;
|
inviteLink: string;
|
||||||
toEmail: string;
|
toEmail: string;
|
||||||
|
organizationName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InvitationEmail = ({
|
export const InvitationEmail = ({
|
||||||
inviteLink,
|
inviteLink,
|
||||||
toEmail,
|
toEmail,
|
||||||
}: VercelInviteUserEmailProps) => {
|
organizationName = "an organization",
|
||||||
const previewText = "Join to Dokploy";
|
}: InvitationEmailProps) => {
|
||||||
|
const previewText = `You've been invited to join ${organizationName} on Dokploy`;
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
@@ -44,50 +41,67 @@ export const InvitationEmail = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||||
<Section className="mt-[32px]">
|
{/* Header */}
|
||||||
|
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||||
<Img
|
<Img
|
||||||
src={
|
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||||
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
|
width="190"
|
||||||
}
|
height="120"
|
||||||
width="100"
|
|
||||||
height="50"
|
|
||||||
alt="Dokploy"
|
alt="Dokploy"
|
||||||
className="my-0 mx-auto"
|
className="my-0 mx-auto"
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
|
||||||
Join to <strong>Dokploy</strong>
|
{/* Body */}
|
||||||
</Heading>
|
<Section className="bg-white px-[40px] py-[32px]">
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||||
Hello,
|
You've been invited to join {organizationName}
|
||||||
</Text>
|
</Heading>
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||||
You have been invited to join <strong>Dokploy</strong>, a platform
|
You have been invited to join{" "}
|
||||||
that helps for deploying your apps to the cloud.
|
<strong className="text-[#09090b]">{organizationName}</strong>{" "}
|
||||||
</Text>
|
on Dokploy, the platform for deploying your apps to the cloud.
|
||||||
<Section className="text-center mt-[32px] mb-[32px]">
|
Click the button below to accept the invitation.
|
||||||
<Button
|
</Text>
|
||||||
href={inviteLink}
|
|
||||||
className="bg-[#000000] rounded text-white text-[12px] font-semibold no-underline text-center px-5 py-3"
|
{/* CTA Button */}
|
||||||
>
|
<Section className="text-center mb-[24px]">
|
||||||
Join the team 🚀
|
<Button
|
||||||
</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>
|
||||||
</Section>
|
</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>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Tailwind>
|
</Tailwind>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export * from "./utils/notifications/docker-cleanup";
|
|||||||
export * from "./utils/notifications/dokploy-restart";
|
export * from "./utils/notifications/dokploy-restart";
|
||||||
export * from "./utils/notifications/server-threshold";
|
export * from "./utils/notifications/server-threshold";
|
||||||
export * from "./utils/notifications/utils";
|
export * from "./utils/notifications/utils";
|
||||||
|
export * from "./verification/send-verification-email";
|
||||||
export * from "./utils/process/execAsync";
|
export * from "./utils/process/execAsync";
|
||||||
export * from "./utils/process/spawnAsync";
|
export * from "./utils/process/spawnAsync";
|
||||||
export * from "./utils/providers/bitbucket";
|
export * from "./utils/providers/bitbucket";
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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(),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import { apiKey } from "@better-auth/api-key";
|
import { apiKey } from "@better-auth/api-key";
|
||||||
import { scim } from "@better-auth/scim";
|
|
||||||
import { sso } from "@better-auth/sso";
|
import { sso } from "@better-auth/sso";
|
||||||
import * as bcrypt from "bcrypt";
|
import * as bcrypt from "bcrypt";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
@@ -179,8 +178,7 @@ const { handler, api } = betterAuth({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const isSSORequest = context?.path.includes("/sso");
|
const isSSORequest = context?.path.includes("/sso");
|
||||||
const isSCIMRequest = context?.path.includes("/scim");
|
if (isSSORequest) {
|
||||||
if (isSSORequest || isSCIMRequest) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
@@ -196,7 +194,6 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
after: async (user, context) => {
|
after: async (user, context) => {
|
||||||
const isSSORequest = context?.path.includes("/sso");
|
const isSSORequest = context?.path.includes("/sso");
|
||||||
const isSCIMRequest = context?.path.includes("/scim");
|
|
||||||
const isAdminPresent = await db.query.member.findFirst({
|
const isAdminPresent = await db.query.member.findFirst({
|
||||||
where: eq(schema.member.role, "owner"),
|
where: eq(schema.member.role, "owner"),
|
||||||
});
|
});
|
||||||
@@ -232,10 +229,6 @@ const { handler, api } = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSCIMRequest) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IS_CLOUD || !isAdminPresent) {
|
if (IS_CLOUD || !isAdminPresent) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const organization = await tx
|
const organization = await tx
|
||||||
@@ -403,24 +396,7 @@ const { handler, api } = betterAuth({
|
|||||||
enableMetadata: true,
|
enableMetadata: true,
|
||||||
references: "user",
|
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(),
|
twoFactor(),
|
||||||
organization({
|
organization({
|
||||||
ac,
|
ac,
|
||||||
@@ -433,23 +409,6 @@ const { handler, api } = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
maximumRolesPerOrganization: 10,
|
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
|
...(IS_CLOUD
|
||||||
? [
|
? [
|
||||||
@@ -466,9 +425,6 @@ const _auth = {
|
|||||||
createApiKey: api.createApiKey,
|
createApiKey: api.createApiKey,
|
||||||
registerSSOProvider: api.registerSSOProvider,
|
registerSSOProvider: api.registerSSOProvider,
|
||||||
updateSSOProvider: api.updateSSOProvider,
|
updateSSOProvider: api.updateSSOProvider,
|
||||||
generateSCIMToken: api.generateSCIMToken,
|
|
||||||
listSCIMProviderConnections: api.listSCIMProviderConnections,
|
|
||||||
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthType = typeof _auth;
|
export type AuthType = typeof _auth;
|
||||||
@@ -508,8 +464,10 @@ export const validateRequest = async (request: IncomingMessage) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = JSON.parse(
|
const organizationId = (
|
||||||
apiKeyRecord.metadata || "{}",
|
JSON.parse(apiKeyRecord.metadata || "{}") as {
|
||||||
|
organizationId?: string;
|
||||||
|
}
|
||||||
).organizationId;
|
).organizationId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
|
|||||||
@@ -30,13 +30,9 @@ export const findPreviewDeploymentById = async (
|
|||||||
with: {
|
with: {
|
||||||
domain: true,
|
domain: true,
|
||||||
application: {
|
application: {
|
||||||
with: {
|
columns: {
|
||||||
server: true,
|
applicationId: true,
|
||||||
environment: {
|
serverId: true,
|
||||||
with: {
|
|
||||||
project: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function parseRawConfig(
|
|||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
parsedLogs = parsedLogs.filter((log) =>
|
parsedLogs = parsedLogs.filter((log) =>
|
||||||
log.RequestPath.toLowerCase().includes(search.toLowerCase()),
|
log.RequestHost.toLowerCase().includes(search.toLowerCase()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { renderAsync } from "@react-email/components";
|
import { renderAsync } from "@react-email/components";
|
||||||
|
import InvitationEmail from "../emails/emails/invitation";
|
||||||
import VerifyEmailTemplate from "../emails/emails/verify-email";
|
import VerifyEmailTemplate from "../emails/emails/verify-email";
|
||||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||||
|
|
||||||
@@ -51,3 +52,42 @@ export const sendVerificationEmail = async ({
|
|||||||
text: html,
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
782
pnpm-lock.yaml
generated
782
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user