mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
feat: integrate vault provider management and environment variable resolution
- Added support for managing various vault providers (HashiCorp, AWS, Azure, Doppler, Infisical) in the dashboard. - Implemented environment variable resolution using vault references, allowing seamless integration of secrets into application environments. - Enhanced UI components to display and manage vault providers effectively. - Introduced tests for vault reference resolution and environment variable preparation. - Updated related components to utilize new vault management features.
This commit is contained in:
parent
771d76a57c
commit
b8822a1fde
316
apps/dokploy/__test__/env/vault.test.ts
vendored
Normal file
316
apps/dokploy/__test__/env/vault.test.ts
vendored
Normal file
@ -0,0 +1,316 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.fn();
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findMany: (...args: unknown[]) => findMany(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
resolveVaultReferences,
|
||||
withResolvedVaultRefs,
|
||||
} from "@dokploy/server/utils/vault";
|
||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("resolveVaultReferences", () => {
|
||||
it("returns input untouched and skips the db when no refs are present", async () => {
|
||||
const env = "FOO=bar\nBAZ=${{project.QUX}}";
|
||||
const result = await resolveVaultReferences(env, "org-1");
|
||||
expect(result).toBe(env);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null/empty inputs unchanged", async () => {
|
||||
expect(await resolveVaultReferences(null, "org-1")).toBeNull();
|
||||
expect(await resolveVaultReferences("", "org-1")).toBe("");
|
||||
});
|
||||
|
||||
it("throws when refs exist but no organization context is given", async () => {
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
|
||||
).rejects.toThrow("not supported in this context");
|
||||
});
|
||||
|
||||
it("throws for an unknown provider", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", "org-1"),
|
||||
).rejects.toThrow('Vault provider "missing" not found');
|
||||
});
|
||||
|
||||
it("resolves refs through a doppler provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "doppler-prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
|
||||
);
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
|
||||
"org-1",
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when the secret is missing in the provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "doppler-prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
|
||||
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", "org-1"),
|
||||
).rejects.toThrow('secret "MISSING" not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
|
||||
it("resolves entity env sources so sync interpolation sees real values", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
|
||||
|
||||
const entity = {
|
||||
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
|
||||
environment: {
|
||||
env: null,
|
||||
project: {
|
||||
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = await withResolvedVaultRefs(entity);
|
||||
const prepared = prepareEnvironmentVariables(
|
||||
resolved.env,
|
||||
resolved.environment.project.env,
|
||||
resolved.environment.env,
|
||||
);
|
||||
|
||||
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
|
||||
});
|
||||
|
||||
it("resolves buildArgs and buildSecrets when present", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
|
||||
|
||||
const resolved = await withResolvedVaultRefs({
|
||||
env: "FOO=bar",
|
||||
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
|
||||
buildSecrets: null,
|
||||
environment: {
|
||||
env: null,
|
||||
project: { env: null, organizationId: "org-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
|
||||
expect(resolved.buildSecrets).toBeNull();
|
||||
expect(resolved.env).toBe("FOO=bar");
|
||||
});
|
||||
|
||||
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
|
||||
).toThrow("Unresolved vault reference");
|
||||
});
|
||||
|
||||
it("keeps working without vault refs", () => {
|
||||
const prepared = prepareEnvironmentVariables(
|
||||
"FOO=${{project.BAR}}",
|
||||
"BAR=baz",
|
||||
);
|
||||
expect(prepared).toEqual(["FOO=baz"]);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashicorp client", () => {
|
||||
const config = {
|
||||
providerType: "hashicorp" as const,
|
||||
url: "https://vault.example.com",
|
||||
token: "hvs.token",
|
||||
mount: "secret",
|
||||
};
|
||||
|
||||
it("rejects refs without a field", async () => {
|
||||
await expect(
|
||||
hashicorpClient.getSecrets(config, ["myapp/prod"]),
|
||||
).rejects.toThrow("expected format <path>:<field>");
|
||||
});
|
||||
|
||||
it("groups refs by path and picks fields from KV v2 data", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
|
||||
);
|
||||
|
||||
const result = await hashicorpClient.getSecrets(config, [
|
||||
"myapp/prod:USER",
|
||||
"myapp/prod:PASS",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"myapp/prod:USER": "admin",
|
||||
"myapp/prod:PASS": "pw",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://vault.example.com/v1/secret/data/myapp/prod",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a field is missing", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
|
||||
await expect(
|
||||
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
|
||||
).rejects.toThrow('field "MISSING" not found');
|
||||
});
|
||||
|
||||
it("lists full path:field refs by walking directories", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/metadata?list=true")) {
|
||||
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
|
||||
}
|
||||
if (url.includes("/metadata/myapp?list=true")) {
|
||||
return jsonResponse({ data: { keys: ["prod"] } });
|
||||
}
|
||||
if (url.includes("/data/myapp/prod")) {
|
||||
return jsonResponse({
|
||||
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
|
||||
});
|
||||
}
|
||||
if (url.includes("/data/shared")) {
|
||||
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
|
||||
}
|
||||
return jsonResponse({}, false, 404);
|
||||
});
|
||||
|
||||
const names = await hashicorpClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual([
|
||||
"myapp/prod:API_KEY",
|
||||
"myapp/prod:DB_PASSWORD",
|
||||
"shared:STRIPE_KEY",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("azure client", () => {
|
||||
const config = {
|
||||
providerType: "azure" as const,
|
||||
vaultUri: "https://my-vault.vault.azure.net",
|
||||
tenantId: "tenant-1",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
};
|
||||
|
||||
it("authenticates and reads secrets by name", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("login.microsoftonline.com/tenant-1")) {
|
||||
return jsonResponse({ access_token: "azure-token" });
|
||||
}
|
||||
if (url.includes("/secrets/db-password?")) {
|
||||
return jsonResponse({ value: "azure-pw" });
|
||||
}
|
||||
return jsonResponse({}, false, 404);
|
||||
});
|
||||
|
||||
const result = await azureClient.getSecrets(config, ["db-password"]);
|
||||
|
||||
expect(result).toEqual({ "db-password": "azure-pw" });
|
||||
});
|
||||
|
||||
it("throws a clear error for missing secrets", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return jsonResponse({ access_token: "azure-token" });
|
||||
}
|
||||
return jsonResponse({}, false, 404);
|
||||
});
|
||||
|
||||
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
||||
'secret "nope" not found',
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names from paged results", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return jsonResponse({ access_token: "azure-token" });
|
||||
}
|
||||
if (url.includes("skiptoken")) {
|
||||
return jsonResponse({
|
||||
value: [{ id: `${config.vaultUri}/secrets/second` }],
|
||||
nextLink: null,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
value: [{ id: `${config.vaultUri}/secrets/first` }],
|
||||
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
|
||||
});
|
||||
});
|
||||
|
||||
const names = await azureClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["first", "second"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("doppler client", () => {
|
||||
it("propagates auth errors with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||
await expect(
|
||||
dopplerClient.getSecrets(
|
||||
{ providerType: "doppler", serviceToken: "bad" },
|
||||
["FOO"],
|
||||
),
|
||||
).rejects.toThrow("status 401");
|
||||
});
|
||||
});
|
||||
@ -39,6 +39,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -58,6 +59,10 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||
const completionSource = useEnvCompletionSource({
|
||||
projectEnv: data?.environment?.project?.env,
|
||||
environmentEnv: data?.environment?.env,
|
||||
});
|
||||
|
||||
const mutationMap = {
|
||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
||||
@ -198,6 +203,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
} as CSSProperties
|
||||
}
|
||||
language="properties"
|
||||
completionSource={completionSource}
|
||||
disabled={isEnvVisible}
|
||||
className="font-mono"
|
||||
wrapperClassName="compose-file-editor"
|
||||
|
||||
@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
@ -45,6 +46,11 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const completionSource = useEnvCompletionSource({
|
||||
projectEnv: data?.environment?.project?.env,
|
||||
environmentEnv: data?.environment?.env,
|
||||
});
|
||||
|
||||
const form = useForm<EnvironmentSchema>({
|
||||
defaultValues: {
|
||||
env: "",
|
||||
@ -142,6 +148,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
</span>
|
||||
}
|
||||
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||
completionSource={completionSource}
|
||||
/>
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
@ -163,6 +170,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
completionSource={completionSource}
|
||||
/>
|
||||
)}
|
||||
{data?.buildType === "dockerfile" && (
|
||||
@ -185,6 +193,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
completionSource={completionSource}
|
||||
/>
|
||||
)}
|
||||
{data?.buildType === "dockerfile" && (
|
||||
|
||||
@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -55,6 +56,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const completionSource = useEnvCompletionSource({
|
||||
includeShared: false,
|
||||
});
|
||||
const form = useForm<UpdateEnvironment>({
|
||||
defaultValues: {
|
||||
env: data?.env ?? "",
|
||||
@ -134,7 +138,9 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
<AlertBlock type="info">
|
||||
Use this syntax to reference environment-level variables in your
|
||||
service environments:{" "}
|
||||
<code>API_URL=${"{{environment.API_URL}}"}</code>
|
||||
<code>API_URL=${"{{environment.API_URL}}"}</code>. You can also
|
||||
reference secrets from a configured vault provider:{" "}
|
||||
<code>DB_URL=${"{{vault.<provider>.<secret>}}"}</code>
|
||||
</AlertBlock>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
@ -151,6 +157,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
lineWrapping
|
||||
language="properties"
|
||||
readOnly={!canWrite}
|
||||
|
||||
@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -55,6 +56,9 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const completionSource = useEnvCompletionSource({
|
||||
includeShared: false,
|
||||
});
|
||||
const form = useForm<UpdateProject>({
|
||||
defaultValues: {
|
||||
env: data?.env ?? "",
|
||||
@ -77,6 +81,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
.then(() => {
|
||||
toast.success("Project env updated successfully");
|
||||
utils.project.all.invalidate();
|
||||
utils.project.one.invalidate({ projectId });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the env");
|
||||
@ -149,6 +154,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
lineWrapping
|
||||
language="properties"
|
||||
readOnly={!canWrite}
|
||||
|
||||
@ -0,0 +1,837 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { vaultProviderIcons } from "@/components/icons/vault-provider-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const providerLabels = {
|
||||
hashicorp: "HashiCorp Vault / OpenBao",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
|
||||
const VaultProviderSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: "Name is required" })
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message:
|
||||
"Only letters, numbers, dashes and underscores (used in ${{vault.<name>.<secret>}})",
|
||||
}),
|
||||
providerType: z.enum(["hashicorp", "infisical", "aws", "doppler", "azure"]),
|
||||
url: z.string(),
|
||||
token: z.string(),
|
||||
namespace: z.string(),
|
||||
mount: z.string(),
|
||||
siteUrl: z.string(),
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentSlug: z.string(),
|
||||
secretPath: z.string(),
|
||||
region: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
serviceToken: z.string(),
|
||||
awsEndpoint: z.string(),
|
||||
vaultUri: z.string(),
|
||||
tenantId: z.string(),
|
||||
azureClientId: z.string(),
|
||||
azureClientSecret: z.string(),
|
||||
dopplerProject: z.string(),
|
||||
dopplerConfig: z.string(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const isValidUrl = (value: string) => {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
data.providerType === "hashicorp" &&
|
||||
data.url &&
|
||||
!isValidUrl(data.url)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL (e.g. https://vault.example.com:8200)",
|
||||
path: ["url"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "azure" &&
|
||||
data.vaultUri &&
|
||||
!isValidUrl(data.vaultUri)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL (e.g. https://my-vault.vault.azure.net)",
|
||||
path: ["vaultUri"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "aws" &&
|
||||
data.awsEndpoint &&
|
||||
!isValidUrl(data.awsEndpoint)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL",
|
||||
path: ["awsEndpoint"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "infisical" &&
|
||||
data.siteUrl &&
|
||||
!isValidUrl(data.siteUrl)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL (e.g. https://app.infisical.com)",
|
||||
path: ["siteUrl"],
|
||||
});
|
||||
}
|
||||
|
||||
const required: Partial<
|
||||
Record<ProviderType, [keyof typeof data, string][]>
|
||||
> = {
|
||||
hashicorp: [
|
||||
["url", "Vault URL is required"],
|
||||
["token", "Token is required"],
|
||||
["mount", "Mount is required"],
|
||||
],
|
||||
infisical: [
|
||||
["siteUrl", "Site URL is required"],
|
||||
["clientId", "Client ID is required"],
|
||||
["clientSecret", "Client Secret is required"],
|
||||
["projectId", "Project ID is required"],
|
||||
["environmentSlug", "Environment is required"],
|
||||
],
|
||||
aws: [
|
||||
["region", "Region is required"],
|
||||
["accessKeyId", "Access Key ID is required"],
|
||||
["secretAccessKey", "Secret Access Key is required"],
|
||||
],
|
||||
doppler: [["serviceToken", "Service Token is required"]],
|
||||
azure: [
|
||||
["vaultUri", "Vault URI is required"],
|
||||
["tenantId", "Tenant ID is required"],
|
||||
["azureClientId", "Client ID is required"],
|
||||
["azureClientSecret", "Client Secret is required"],
|
||||
],
|
||||
};
|
||||
|
||||
for (const [field, message] of required[data.providerType] ?? []) {
|
||||
if (!data[field]) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message,
|
||||
path: [field],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
data.providerType === "doppler" &&
|
||||
data.serviceToken &&
|
||||
!data.serviceToken.startsWith("dp.st.") &&
|
||||
data.serviceToken !== "********"
|
||||
) {
|
||||
for (const field of ["dopplerProject", "dopplerConfig"] as const) {
|
||||
if (!data[field]) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Required for personal/CLI tokens (dp.pt. / dp.ct.)",
|
||||
path: [field],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type VaultProviderForm = z.infer<typeof VaultProviderSchema>;
|
||||
|
||||
const defaultValues: VaultProviderForm = {
|
||||
name: "",
|
||||
providerType: "hashicorp",
|
||||
url: "",
|
||||
token: "",
|
||||
namespace: "",
|
||||
mount: "secret",
|
||||
siteUrl: "https://app.infisical.com",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
projectId: "",
|
||||
environmentSlug: "",
|
||||
secretPath: "/",
|
||||
region: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
serviceToken: "",
|
||||
awsEndpoint: "",
|
||||
vaultUri: "",
|
||||
tenantId: "",
|
||||
azureClientId: "",
|
||||
azureClientSecret: "",
|
||||
dopplerProject: "",
|
||||
dopplerConfig: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: VaultProviderForm) => {
|
||||
switch (data.providerType) {
|
||||
case "hashicorp":
|
||||
return {
|
||||
providerType: "hashicorp" as const,
|
||||
url: data.url,
|
||||
token: data.token,
|
||||
namespace: data.namespace || undefined,
|
||||
mount: data.mount || "secret",
|
||||
};
|
||||
case "infisical":
|
||||
return {
|
||||
providerType: "infisical" as const,
|
||||
siteUrl: data.siteUrl || "https://app.infisical.com",
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
projectId: data.projectId,
|
||||
environmentSlug: data.environmentSlug,
|
||||
secretPath: data.secretPath || "/",
|
||||
};
|
||||
case "aws":
|
||||
return {
|
||||
providerType: "aws" as const,
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.awsEndpoint || undefined,
|
||||
};
|
||||
case "doppler":
|
||||
return {
|
||||
providerType: "doppler" as const,
|
||||
serviceToken: data.serviceToken,
|
||||
project: data.dopplerProject || undefined,
|
||||
config: data.dopplerConfig || undefined,
|
||||
};
|
||||
case "azure":
|
||||
return {
|
||||
providerType: "azure" as const,
|
||||
vaultUri: data.vaultUri,
|
||||
tenantId: data.tenantId,
|
||||
clientId: data.azureClientId,
|
||||
clientSecret: data.azureClientSecret,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const extractErrorMessage = (err: unknown) => {
|
||||
if (!(err instanceof Error)) return undefined;
|
||||
try {
|
||||
const issues = JSON.parse(err.message) as { message?: string }[];
|
||||
if (Array.isArray(issues)) {
|
||||
return issues
|
||||
.map((issue) => issue.message)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
} catch {}
|
||||
return err.message;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
vaultProviderId?: string;
|
||||
}
|
||||
|
||||
export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: provider } = api.vaultProvider.one.useQuery(
|
||||
{ vaultProviderId: vaultProviderId || "" },
|
||||
{ enabled: !!vaultProviderId && isOpen },
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = vaultProviderId
|
||||
? api.vaultProvider.update.useMutation()
|
||||
: api.vaultProvider.create.useMutation();
|
||||
|
||||
const { mutateAsync: testConnection, isPending: isTesting } =
|
||||
api.vaultProvider.testConnection.useMutation();
|
||||
|
||||
const form = useForm<VaultProviderForm>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(VaultProviderSchema),
|
||||
});
|
||||
|
||||
const providerType = form.watch("providerType");
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
form.reset({
|
||||
...defaultValues,
|
||||
name: provider.name,
|
||||
providerType: provider.config.providerType,
|
||||
...(provider.config.providerType === "hashicorp" && {
|
||||
url: provider.config.url,
|
||||
token: provider.config.token,
|
||||
namespace: provider.config.namespace ?? "",
|
||||
mount: provider.config.mount,
|
||||
}),
|
||||
...(provider.config.providerType === "infisical" && {
|
||||
siteUrl: provider.config.siteUrl,
|
||||
clientId: provider.config.clientId,
|
||||
clientSecret: provider.config.clientSecret,
|
||||
projectId: provider.config.projectId,
|
||||
environmentSlug: provider.config.environmentSlug,
|
||||
secretPath: provider.config.secretPath,
|
||||
}),
|
||||
...(provider.config.providerType === "aws" && {
|
||||
region: provider.config.region,
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
awsEndpoint: provider.config.endpoint ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "doppler" && {
|
||||
serviceToken: provider.config.serviceToken,
|
||||
dopplerProject: provider.config.project ?? "",
|
||||
dopplerConfig: provider.config.config ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "azure" && {
|
||||
vaultUri: provider.config.vaultUri,
|
||||
tenantId: provider.config.tenantId,
|
||||
azureClientId: provider.config.clientId,
|
||||
azureClientSecret: provider.config.clientSecret,
|
||||
}),
|
||||
});
|
||||
} else if (!vaultProviderId) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [provider, vaultProviderId, form, isOpen]);
|
||||
|
||||
const onSubmit = async (data: VaultProviderForm) => {
|
||||
const payload: any = {
|
||||
name: data.name,
|
||||
config: buildConfig(data),
|
||||
...(vaultProviderId && { vaultProviderId }),
|
||||
};
|
||||
await mutateAsync(payload)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
vaultProviderId ? "Vault provider updated" : "Vault provider created",
|
||||
);
|
||||
utils.vaultProvider.all.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const onTestConnection = async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const data = form.getValues();
|
||||
await testConnection({
|
||||
config: buildConfig(data),
|
||||
...(vaultProviderId && { vaultProviderId }),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Connection successful");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Connection failed", {
|
||||
description: extractErrorMessage(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{vaultProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{vaultProviderId ? "Update Vault Provider" : "Add Vault Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reference secrets in your environment variables with{" "}
|
||||
<code>{"${{vault.<name>.<secret>}}"}</code>. Secrets are fetched at
|
||||
deploy time and never stored in Dokploy.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="prod-vault" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={!!vaultProviderId}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(providerLabels).map(([value, label]) => {
|
||||
const ProviderIcon =
|
||||
vaultProviderIcons[value as ProviderType];
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{providerType === "hashicorp" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vault URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://vault.example.com:8200"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>KV Mount</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="secret" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="namespace"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Namespace (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="admin" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.path/to/secret:FIELD}}"}</code>
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "infisical" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="siteUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Site URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://app.infisical.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="environmentSlug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Environment</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="prod" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "aws" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="region"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="us-east-1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretAccessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="awsEndpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://localhost:4566" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom endpoint for VPC endpoints or API-compatible
|
||||
emulators
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
||||
secrets
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "azure" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vaultUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Vault URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://my-vault.vault.azure.net"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tenantId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tenant ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="azureClientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="azureClientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
App Registration with the "Key Vault Secrets User" role on
|
||||
the vault. Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code>
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "doppler" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serviceToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="dp.st..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Service tokens (dp.st.) are recommended: read-only and
|
||||
scoped to a single project + config
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dopplerProject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-project" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dopplerConfig"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Config (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="prd" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Only needed for personal (dp.pt.) or CLI (dp.ct.) tokens —
|
||||
service tokens already carry them
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isTesting}
|
||||
onClick={onTestConnection}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{vaultProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,154 @@
|
||||
import { Loader2, Trash2, Vault } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { vaultProviderIcons } from "@/components/icons/vault-provider-icons";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleVaultProvider } from "./handle-vault-provider";
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
hashicorp: "HashiCorp Vault",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
};
|
||||
|
||||
export const ShowVaultProviders = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.vaultProvider.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.vaultProvider.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Vault className="size-6 text-muted-foreground self-center" />
|
||||
Secrets Providers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect external secret managers and reference their secrets in
|
||||
environment variables with{" "}
|
||||
<code>{"${{vault.<name>.<secret>}}"}</code>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Vault className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any secrets providers configured
|
||||
</span>
|
||||
{permissions?.vaultProvider.create && (
|
||||
<HandleVaultProvider />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{data?.map((provider) => {
|
||||
const ProviderIcon =
|
||||
vaultProviderIcons[provider.providerType];
|
||||
return (
|
||||
<div
|
||||
key={provider.vaultProviderId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ProviderIcon className="size-7 shrink-0" />
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{provider.name}
|
||||
</span>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Badge variant="outline">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{"${{vault." + provider.name + ".…}}"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
{permissions?.vaultProvider.update && (
|
||||
<HandleVaultProvider
|
||||
vaultProviderId={provider.vaultProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.vaultProvider.delete && (
|
||||
<DialogAction
|
||||
title="Delete Secrets Provider"
|
||||
description="Deployments referencing this provider will fail. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
vaultProviderId:
|
||||
provider.vaultProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Secrets provider deleted",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the secrets provider",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{permissions?.vaultProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleVaultProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
544
apps/dokploy/components/icons/vault-provider-icons.tsx
Normal file
544
apps/dokploy/components/icons/vault-provider-icons.tsx
Normal file
@ -0,0 +1,544 @@
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const HashicorpVaultIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M0 0l11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfisicalIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 91 43"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M21.9734 0C24.8526 0 27.4793 0.412021 29.8535 1.23606C32.2528 2.06011 34.4123 3.13386 36.3318 4.45732C38.2766 5.75581 39.9814 7.10424 41.4463 8.50261C42.2545 9.25174 42.987 9.98839 43.6436 10.7125C44.3003 11.4367 44.9317 12.1609 45.5379 12.885C46.0935 12.1609 46.6618 11.4617 47.2427 10.7875C47.8489 10.0883 48.6066 9.32665 49.5158 8.50261C51.7132 6.38008 54.4409 4.43235 57.699 2.65941C60.9824 0.886471 64.7835 0 69.1024 0C73.1435 0 76.8183 0.97387 80.127 2.92161C83.4609 4.84437 86.1002 7.42886 88.045 10.6751C90.015 13.9213 91 17.5171 91 21.4625C91 24.4591 90.4317 27.2683 89.2952 29.8902C88.1586 32.4872 86.5927 34.7721 84.5974 36.7448C82.6021 38.6925 80.2785 40.2282 77.6266 41.3519C74.9746 42.4506 72.1332 43 69.1024 43C66.2231 43 63.5712 42.6005 61.1465 41.8014C58.7472 40.9774 56.5751 39.9286 54.6303 38.6551C52.7108 37.3815 51.0186 36.0706 49.5537 34.7221C48.7202 33.8981 47.9752 33.124 47.3185 32.3998C46.6871 31.6507 46.0935 30.9141 45.5379 30.1899C44.9065 30.9141 44.2624 31.6507 43.6057 32.3998C42.9491 33.149 42.2166 33.9231 41.4084 34.7221C39.9688 36.0706 38.2766 37.3815 36.3318 38.6551C34.4123 39.9036 32.2528 40.9399 29.8535 41.7639C27.4793 42.588 24.8526 43 21.9734 43C17.8818 43 14.1817 42.0386 10.873 40.1159C7.56439 38.1931 4.92506 35.6086 2.95504 32.3624C0.985013 29.0912 0 25.4579 0 21.4625C0 18.491 0.555648 15.7192 1.66694 13.1472C2.8035 10.5502 4.36941 8.26539 6.3647 6.29269C8.38523 4.31998 10.7215 2.78427 13.3734 1.68554C16.0507 0.561848 18.9173 0 21.9734 0ZM10.4563 21.4625C10.4563 23.5351 10.974 25.4204 12.0096 27.1185C13.0451 28.8165 14.4342 30.1649 16.1769 31.1638C17.9197 32.1626 19.8518 32.662 21.9734 32.662C24.3475 32.662 26.5322 32.1376 28.5275 31.0889C30.5228 30.0401 32.3791 28.7166 34.0966 27.1185C35.1826 26.0947 36.1171 25.1083 36.9001 24.1594C37.683 23.2105 38.365 22.3116 38.9459 21.4625C38.3397 20.6635 37.6199 19.777 36.7864 18.8031C35.9782 17.8043 35.0816 16.8554 34.0966 15.9564C32.4802 14.3833 30.649 13.0598 28.6032 11.9861C26.5575 10.8873 24.3475 10.338 21.9734 10.338C19.8518 10.338 17.9197 10.8499 16.1769 11.8737C14.4342 12.8725 13.0451 14.221 12.0096 15.919C10.974 17.592 10.4563 19.4399 10.4563 21.4625ZM80.4679 21.4625C80.4679 19.4399 79.9502 17.592 78.9146 15.919C77.9044 14.221 76.5405 12.8725 74.8231 11.8737C73.1056 10.8499 71.1987 10.338 69.1024 10.338C67.486 10.338 65.9453 10.5877 64.4804 11.0871C63.0155 11.5865 61.639 12.2607 60.351 13.1098C59.0881 13.9588 57.9263 14.9077 56.8655 15.9564C55.729 17.0052 54.7313 18.079 53.8726 19.1777C53.0139 20.2515 52.3951 21.0131 52.0162 21.4625C52.6477 22.3365 53.3549 23.248 54.1378 24.1969C54.9208 25.1208 55.83 26.0947 56.8655 27.1185C58.5577 28.7166 60.4015 30.0401 62.3968 31.0889C64.4173 32.1376 66.6525 32.662 69.1024 32.662C71.1987 32.662 73.1056 32.1626 74.8231 31.1638C76.5405 30.1649 77.9044 28.8165 78.9146 27.1185C79.9502 25.4204 80.4679 23.5351 80.4679 21.4625Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AwsIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 304 182"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
|
||||
/>
|
||||
<path
|
||||
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
|
||||
fill="#f90"
|
||||
/>
|
||||
<path
|
||||
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
|
||||
fill="#f90"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DopplerIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 101 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
|
||||
fill="url(#paint0_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
|
||||
fill="url(#paint1_linear_1068_18455)"
|
||||
fill-opacity="0.46"
|
||||
/>
|
||||
<path
|
||||
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
|
||||
fill="url(#paint2_linear_1068_18455)"
|
||||
fill-opacity="0.66"
|
||||
/>
|
||||
<path
|
||||
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
|
||||
fill="url(#paint3_linear_1068_18455)"
|
||||
fill-opacity="0.67"
|
||||
/>
|
||||
<path
|
||||
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
|
||||
fill="url(#paint4_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
|
||||
fill="url(#paint5_radial_1068_18455)"
|
||||
fill-opacity="0.94"
|
||||
/>
|
||||
<path
|
||||
d="M64.3092 0.259394C60.1163 -0.864113 55.8423 1.77302 54.9657 6.02449L50.9884 25.3158C48.3904 37.9168 38.5465 47.7653 25.9467 50.3692L7.02287 54.2801C2.77187 55.1586 0.13598 59.4329 1.25947 63.6258C2.38309 67.8192 6.8038 70.2029 10.9248 68.8373L29.3407 62.7351C41.5851 58.6779 55.0695 62.291 63.6448 71.9269L76.5434 86.4207C79.4289 89.6631 84.4482 89.8092 87.5174 86.74C90.5871 83.6703 90.4404 78.6501 87.1969 75.7648L72.609 62.7885C62.9468 54.1936 59.3239 40.6746 63.3932 28.3998L69.5188 9.92278C70.8847 5.80285 68.5018 1.38278 64.3092 0.259394Z"
|
||||
fill="url(#paint6_radial_1068_18455)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
|
||||
fill="url(#paint7_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
|
||||
fill="url(#paint8_linear_1068_18455)"
|
||||
fill-opacity="0.46"
|
||||
/>
|
||||
<path
|
||||
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
|
||||
fill="url(#paint9_linear_1068_18455)"
|
||||
fill-opacity="0.66"
|
||||
/>
|
||||
<path
|
||||
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
|
||||
fill="url(#paint10_linear_1068_18455)"
|
||||
fill-opacity="0.67"
|
||||
/>
|
||||
<path
|
||||
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
|
||||
fill="url(#paint11_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
|
||||
fill="url(#paint12_radial_1068_18455)"
|
||||
fill-opacity="0.94"
|
||||
/>
|
||||
<path
|
||||
d="M14.1852 24.3911C10.7562 21.2887 10.6228 15.9476 13.8926 12.6779C17.1623 9.40812 22.5033 9.54156 25.6057 12.9705L35.3591 23.7506C37.99 26.6584 37.8785 31.1183 35.1058 33.8911C32.333 36.6639 27.873 36.7753 24.9653 34.1444L14.1852 24.3911Z"
|
||||
fill="url(#paint13_radial_1068_18455)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
|
||||
fill="url(#paint14_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
|
||||
fill="url(#paint15_linear_1068_18455)"
|
||||
fill-opacity="0.46"
|
||||
/>
|
||||
<path
|
||||
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
|
||||
fill="url(#paint16_linear_1068_18455)"
|
||||
fill-opacity="0.66"
|
||||
/>
|
||||
<path
|
||||
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
|
||||
fill="url(#paint17_linear_1068_18455)"
|
||||
fill-opacity="0.67"
|
||||
/>
|
||||
<path
|
||||
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
|
||||
fill="url(#paint18_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
|
||||
fill="url(#paint19_radial_1068_18455)"
|
||||
fill-opacity="0.94"
|
||||
/>
|
||||
<path
|
||||
d="M90.0279 30.3514C94.4292 28.933 99.1214 31.488 100.318 35.9545C101.515 40.4211 98.7289 44.9798 94.2081 45.9521L79.9957 49.0088C76.162 49.8333 72.3553 47.5068 71.3404 43.7191C70.3255 39.9314 72.459 36.0133 76.1912 34.8105L90.0279 30.3514Z"
|
||||
fill="url(#paint20_radial_1068_18455)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<path
|
||||
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
|
||||
fill="url(#paint21_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
|
||||
fill="url(#paint22_linear_1068_18455)"
|
||||
fill-opacity="0.46"
|
||||
/>
|
||||
<path
|
||||
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
|
||||
fill="url(#paint23_linear_1068_18455)"
|
||||
fill-opacity="0.66"
|
||||
/>
|
||||
<path
|
||||
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
|
||||
fill="url(#paint24_linear_1068_18455)"
|
||||
fill-opacity="0.67"
|
||||
/>
|
||||
<path
|
||||
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
|
||||
fill="url(#paint25_linear_1068_18455)"
|
||||
/>
|
||||
<path
|
||||
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
|
||||
fill="url(#paint26_radial_1068_18455)"
|
||||
fill-opacity="0.94"
|
||||
/>
|
||||
<path
|
||||
d="M46.5794 93.3443C45.6071 97.8651 41.0484 100.651 36.5818 99.4544C32.1152 98.2575 29.5603 93.5654 30.9786 89.1641L35.4378 75.3274C36.6405 71.5952 40.5587 69.4617 44.3464 70.4766C48.134 71.4915 50.4605 75.2982 49.636 79.1318L46.5794 93.3443Z"
|
||||
fill="url(#paint27_radial_1068_18455)"
|
||||
fill-opacity="0.1"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_1068_18455"
|
||||
x1="7.23327"
|
||||
y1="62.3326"
|
||||
x2="48.1235"
|
||||
y2="30.169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_1068_18455"
|
||||
x1="55.6034"
|
||||
y1="45.8768"
|
||||
x2="41.1422"
|
||||
y2="93.9976"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#A73394" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_1068_18455"
|
||||
x1="84.0271"
|
||||
y1="87.0164"
|
||||
x2="39.3969"
|
||||
y2="54.6034"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.385417" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_1068_18455"
|
||||
x1="25.9331"
|
||||
y1="4.73728"
|
||||
x2="29.9223"
|
||||
y2="37.8983"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.182292" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_1068_18455"
|
||||
x1="64.5793"
|
||||
y1="35.9036"
|
||||
x2="37.6516"
|
||||
y2="52.3594"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
id="paint5_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
|
||||
>
|
||||
<stop stopColor="#F55C15" stopOpacity="0.46" />
|
||||
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint6_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="paint7_linear_1068_18455"
|
||||
x1="7.23327"
|
||||
y1="62.3326"
|
||||
x2="48.1235"
|
||||
y2="30.169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint8_linear_1068_18455"
|
||||
x1="55.6034"
|
||||
y1="45.8768"
|
||||
x2="41.1422"
|
||||
y2="93.9976"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#A73394" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint9_linear_1068_18455"
|
||||
x1="84.0271"
|
||||
y1="87.0164"
|
||||
x2="39.3969"
|
||||
y2="54.6034"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.385417" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint10_linear_1068_18455"
|
||||
x1="25.9331"
|
||||
y1="4.73728"
|
||||
x2="29.9223"
|
||||
y2="37.8983"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.182292" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint11_linear_1068_18455"
|
||||
x1="64.5793"
|
||||
y1="35.9036"
|
||||
x2="37.6516"
|
||||
y2="52.3594"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
id="paint12_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
|
||||
>
|
||||
<stop stopColor="#F55C15" stopOpacity="0.46" />
|
||||
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint13_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="paint14_linear_1068_18455"
|
||||
x1="7.23327"
|
||||
y1="62.3326"
|
||||
x2="48.1235"
|
||||
y2="30.169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint15_linear_1068_18455"
|
||||
x1="55.6034"
|
||||
y1="45.8768"
|
||||
x2="41.1422"
|
||||
y2="93.9976"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#A73394" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint16_linear_1068_18455"
|
||||
x1="84.0271"
|
||||
y1="87.0164"
|
||||
x2="39.3969"
|
||||
y2="54.6034"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.385417" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint17_linear_1068_18455"
|
||||
x1="25.9331"
|
||||
y1="4.73728"
|
||||
x2="29.9223"
|
||||
y2="37.8983"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.182292" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint18_linear_1068_18455"
|
||||
x1="64.5793"
|
||||
y1="35.9036"
|
||||
x2="37.6516"
|
||||
y2="52.3594"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
id="paint19_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
|
||||
>
|
||||
<stop stopColor="#F55C15" stopOpacity="0.46" />
|
||||
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint20_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="paint21_linear_1068_18455"
|
||||
x1="7.23327"
|
||||
y1="62.3326"
|
||||
x2="48.1235"
|
||||
y2="30.169"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="0.422647" stopColor="#F55C15" stopOpacity="0.84" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint22_linear_1068_18455"
|
||||
x1="55.6034"
|
||||
y1="45.8768"
|
||||
x2="41.1422"
|
||||
y2="93.9976"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#A73394" stopOpacity="0" />
|
||||
<stop offset="1" stopColor="#6B13F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint23_linear_1068_18455"
|
||||
x1="84.0271"
|
||||
y1="87.0164"
|
||||
x2="39.3969"
|
||||
y2="54.6034"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.385417" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E2606E" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint24_linear_1068_18455"
|
||||
x1="25.9331"
|
||||
y1="4.73728"
|
||||
x2="29.9223"
|
||||
y2="37.8983"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.182292" stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#CB4758" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint25_linear_1068_18455"
|
||||
x1="64.5793"
|
||||
y1="35.9036"
|
||||
x2="37.6516"
|
||||
y2="52.3594"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#6B13F5" />
|
||||
<stop offset="1" stopColor="#E46373" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<radialGradient
|
||||
id="paint26_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(38.6489 53.1074) rotate(176.204) scale(52.7244 138.365)"
|
||||
>
|
||||
<stop stopColor="#F55C15" stopOpacity="0.46" />
|
||||
<stop offset="0.831969" stopColor="#EE82C6" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint27_radial_1068_18455"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(-1.49331 68.0673) rotate(-19.7843) scale(36.8309 36.7807)"
|
||||
>
|
||||
<stop stopColor="#FF9EFA" />
|
||||
<stop offset="1" stopColor="#DD5A68" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const AzureIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 96 96"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="a" x1="-1032.17" x2="-1059.21" y1="145.31" y2="65.43" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#114a8b"/>
|
||||
<stop offset="1" stopColor="#0669bc"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b" x1="-1023.73" x2="-1029.98" y1="108.08" y2="105.97" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopOpacity=".3"/>
|
||||
<stop offset=".07" stopOpacity=".2"/>
|
||||
<stop offset=".32" stopOpacity=".1"/>
|
||||
<stop offset=".62" stopOpacity=".05"/>
|
||||
<stop offset="1" stopOpacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="c" x1="-1027.16" x2="-997.48" y1="147.64" y2="68.56" gradientTransform="matrix(1 0 0 -1 1075 158)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#3ccbf4"/>
|
||||
<stop offset="1" stopColor="#2892df"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#a)" d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"/>
|
||||
<path fill="#0078d4" d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"/>
|
||||
<path fill="url(#b)" d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"/>
|
||||
<path fill="url(#c)" d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const vaultProviderIcons = {
|
||||
hashicorp: HashicorpVaultIcon,
|
||||
infisical: InfisicalIcon,
|
||||
aws: AwsIcon,
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
} as const;
|
||||
@ -35,6 +35,7 @@ import {
|
||||
Trash2,
|
||||
User,
|
||||
Users,
|
||||
Vault,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@ -350,6 +351,13 @@ const MENU: Menu = {
|
||||
icon: Package,
|
||||
isEnabled: ({ permissions }) => !!permissions?.registry.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Secrets",
|
||||
url: "/dashboard/settings/secrets",
|
||||
icon: Vault,
|
||||
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "S3 Destinations",
|
||||
|
||||
86
apps/dokploy/components/shared/analytics.tsx
Normal file
86
apps/dokploy/components/shared/analytics.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useRouter } from "next/router";
|
||||
import Script from "next/script";
|
||||
import { useEffect } from "react";
|
||||
import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
|
||||
* version only, and translates tracking query params appended by the
|
||||
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
|
||||
*/
|
||||
export const Analytics = () => {
|
||||
const router = useRouter();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloud || !router.isReady) {
|
||||
return;
|
||||
}
|
||||
const { signup, subscription, tier, ...rest } = router.query;
|
||||
|
||||
if (typeof signup === "string" && signup.length > 0) {
|
||||
pushToDataLayer("sign_up", { method: signup });
|
||||
}
|
||||
if (subscription === "new") {
|
||||
pushToDataLayer("new_subscription", {
|
||||
...(typeof tier === "string" ? { tier } : {}),
|
||||
});
|
||||
}
|
||||
if (signup !== undefined || subscription !== undefined) {
|
||||
router.replace({ pathname: router.pathname, query: rest }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}
|
||||
}, [isCloud, router.isReady, router.query, router.pathname, router.replace]);
|
||||
|
||||
if (!isCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script id="analytics-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.hsConversationsSettings = { loadImmediately: false };
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${GTM_ID}');
|
||||
`}
|
||||
</Script>
|
||||
<Script
|
||||
id="hs-script-loader"
|
||||
type="text/javascript"
|
||||
src="//js-eu1.hs-scripts.com/147033433.js"
|
||||
strategy="lazyOnload"
|
||||
async
|
||||
defer
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The HubSpot script is loaded app-wide for marketing tracking, but the
|
||||
* conversations (chat) widget stays restricted to startup plan customers.
|
||||
*/
|
||||
export const useHubSpotChat = (enabled: boolean) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const loadWidget = () => {
|
||||
window.HubSpotConversations?.widget.load();
|
||||
};
|
||||
if (window.HubSpotConversations) {
|
||||
loadWidget();
|
||||
} else {
|
||||
window.hsConversationsOnReady = [
|
||||
...(window.hsConversationsOnReady || []),
|
||||
loadWidget,
|
||||
];
|
||||
}
|
||||
}, [enabled]);
|
||||
};
|
||||
@ -3,6 +3,7 @@ import {
|
||||
type Completion,
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
type CompletionSource,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
@ -135,6 +136,7 @@ interface Props extends ReactCodeMirrorProps {
|
||||
language?: "yaml" | "json" | "properties" | "shell" | "css";
|
||||
lineWrapping?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
completionSource?: CompletionSource;
|
||||
}
|
||||
|
||||
export const CodeEditor = ({
|
||||
@ -142,6 +144,7 @@ export const CodeEditor = ({
|
||||
wrapperClassName,
|
||||
language = "yaml",
|
||||
lineNumbers = true,
|
||||
completionSource,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
@ -175,11 +178,15 @@ export const CodeEditor = ({
|
||||
languageData: { commentTokens: { line: "#" } },
|
||||
}),
|
||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||
language === "yaml"
|
||||
completionSource
|
||||
? autocompletion({
|
||||
override: [dockerComposeComplete],
|
||||
override: [completionSource],
|
||||
})
|
||||
: [],
|
||||
: language === "yaml"
|
||||
? autocompletion({
|
||||
override: [dockerComposeComplete],
|
||||
})
|
||||
: [],
|
||||
]}
|
||||
{...props}
|
||||
editable={!props.disabled}
|
||||
|
||||
166
apps/dokploy/components/shared/env-autocomplete.ts
Normal file
166
apps/dokploy/components/shared/env-autocomplete.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import {
|
||||
type Completion,
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
startCompletion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { useCallback } from "react";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Options {
|
||||
projectEnv?: string | null;
|
||||
environmentEnv?: string | null;
|
||||
includeShared?: boolean;
|
||||
}
|
||||
|
||||
const parseKeys = (env?: string | null) =>
|
||||
(env ?? "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#") && line.includes("="))
|
||||
.map((line) => line.slice(0, line.indexOf("=")).trim())
|
||||
.filter((key) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
|
||||
|
||||
const applyAndContinue = (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: completion.label },
|
||||
selection: { anchor: from + completion.label.length },
|
||||
});
|
||||
startCompletion(view);
|
||||
};
|
||||
|
||||
const applyAndClose = (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
const alreadyClosed = view.state.sliceDoc(to, to + 2) === "}}";
|
||||
const insert = completion.label + (alreadyClosed ? "" : "}}");
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: {
|
||||
anchor: from + completion.label.length + 2,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useEnvCompletionSource = ({
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
includeShared = true,
|
||||
}: Options = {}) => {
|
||||
const { data: providers } = api.vaultProvider.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
|
||||
return useCallback(
|
||||
async (context: CompletionContext): Promise<CompletionResult | null> => {
|
||||
const match = context.matchBefore(/\$\{\{[^}\n]*$/);
|
||||
if (!match) return null;
|
||||
|
||||
const inner = match.text.slice(3);
|
||||
const innerFrom = match.from + 3;
|
||||
|
||||
const vaultSecret = /^vault\.([a-zA-Z0-9_-]+)\.([^}\n]*)$/.exec(inner);
|
||||
if (vaultSecret) {
|
||||
const provider = providers?.find((p) => p.name === vaultSecret[1]);
|
||||
if (!provider) return null;
|
||||
const names = await utils.vaultProvider.listSecretNames
|
||||
.fetch({ vaultProviderId: provider.vaultProviderId })
|
||||
.catch(() => [] as string[]);
|
||||
return {
|
||||
from: innerFrom + `vault.${vaultSecret[1]}.`.length,
|
||||
options: names.map((name) => ({
|
||||
label: name,
|
||||
type: "variable",
|
||||
apply: applyAndClose,
|
||||
})),
|
||||
validFor: /^[^}\n]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
const vaultProviderPrefix = /^vault\.([a-zA-Z0-9_-]*)$/.exec(inner);
|
||||
if (vaultProviderPrefix) {
|
||||
return {
|
||||
from: innerFrom + "vault.".length,
|
||||
options: (providers ?? []).map((provider) => ({
|
||||
label: `${provider.name}.`,
|
||||
detail: provider.providerType,
|
||||
type: "namespace",
|
||||
apply: applyAndContinue,
|
||||
})),
|
||||
validFor: /^[a-zA-Z0-9_-]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
if (includeShared) {
|
||||
const shared: [RegExp, string, string | null | undefined][] = [
|
||||
[/^project\.([^}\n]*)$/, "project.", projectEnv],
|
||||
[/^environment\.([^}\n]*)$/, "environment.", environmentEnv],
|
||||
];
|
||||
for (const [regex, prefix, env] of shared) {
|
||||
if (regex.test(inner)) {
|
||||
return {
|
||||
from: innerFrom + prefix.length,
|
||||
options: parseKeys(env).map((key) => ({
|
||||
label: key,
|
||||
type: "variable",
|
||||
apply: applyAndClose,
|
||||
})),
|
||||
validFor: /^[A-Za-z0-9_]*$/,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!inner.includes(".")) {
|
||||
const options: Completion[] = [];
|
||||
if (includeShared) {
|
||||
options.push(
|
||||
{
|
||||
label: "project.",
|
||||
type: "namespace",
|
||||
info: "Shared project variables",
|
||||
apply: applyAndContinue,
|
||||
},
|
||||
{
|
||||
label: "environment.",
|
||||
type: "namespace",
|
||||
info: "Shared environment variables",
|
||||
apply: applyAndContinue,
|
||||
},
|
||||
);
|
||||
}
|
||||
options.push({
|
||||
label: "vault.",
|
||||
type: "namespace",
|
||||
info: "Secrets from an external vault provider",
|
||||
apply: applyAndContinue,
|
||||
});
|
||||
if (includeShared) {
|
||||
for (const key of parseKeys(context.state.doc.toString())) {
|
||||
options.push({
|
||||
label: key,
|
||||
type: "variable",
|
||||
apply: applyAndClose,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
from: innerFrom,
|
||||
options,
|
||||
validFor: /^[A-Za-z0-9_.-]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[providers, projectEnv, environmentEnv, includeShared, utils],
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import type { CompletionSource } from "@codemirror/autocomplete";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { type CSSProperties, type ReactNode, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
@ -21,6 +22,7 @@ interface Props {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
placeholder: string;
|
||||
completionSource?: CompletionSource;
|
||||
}
|
||||
|
||||
export const Secrets = (props: Props) => {
|
||||
@ -61,6 +63,7 @@ export const Secrets = (props: Props) => {
|
||||
} as CSSProperties
|
||||
}
|
||||
language="properties"
|
||||
completionSource={props.completionSource}
|
||||
disabled={isVisible}
|
||||
lineWrapping
|
||||
placeholder={props.placeholder}
|
||||
|
||||
28
apps/dokploy/lib/analytics.ts
Normal file
28
apps/dokploy/lib/analytics.ts
Normal file
@ -0,0 +1,28 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: Record<string, unknown>[];
|
||||
hsConversationsSettings?: {
|
||||
loadImmediately?: boolean;
|
||||
};
|
||||
hsConversationsOnReady?: (() => void)[];
|
||||
HubSpotConversations?: {
|
||||
widget: {
|
||||
load: () => void;
|
||||
remove: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GTM_ID = "GTM-PWBFB2V2";
|
||||
|
||||
export const pushToDataLayer = (
|
||||
event: string,
|
||||
data: Record<string, unknown> = {},
|
||||
) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push({ event, ...data });
|
||||
};
|
||||
53
apps/dokploy/pages/dashboard/settings/secrets.tsx
Normal file
53
apps/dokploy/pages/dashboard/settings/secrets.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowVaultProviders } from "@/components/dashboard/settings/vault/show-vault-providers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowVaultProviders />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Secrets">{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -50,6 +50,7 @@ import { stripeRouter } from "./routers/stripe";
|
||||
import { swarmRouter } from "./routers/swarm";
|
||||
import { tagRouter } from "./routers/tag";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { vaultProviderRouter } from "./routers/vault-provider";
|
||||
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@ -95,6 +96,7 @@ export const appRouter = createTRPCRouter({
|
||||
stripe: stripeRouter,
|
||||
swarm: swarmRouter,
|
||||
user: userRouter,
|
||||
vaultProvider: vaultProviderRouter,
|
||||
ai: aiRouter,
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
|
||||
147
apps/dokploy/server/api/routers/vault-provider.ts
Normal file
147
apps/dokploy/server/api/routers/vault-provider.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import {
|
||||
createVaultProvider,
|
||||
findVaultProviderById,
|
||||
findVaultProvidersByOrganizationId,
|
||||
listVaultProviderSecretNames,
|
||||
maskVaultProviderConfig,
|
||||
mergeVaultProviderConfig,
|
||||
removeVaultProvider,
|
||||
testVaultProviderConnection,
|
||||
updateVaultProvider,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateVaultProvider,
|
||||
apiFindOneVaultProvider,
|
||||
apiListVaultSecretNames,
|
||||
apiRemoveVaultProvider,
|
||||
apiTestVaultProvider,
|
||||
apiUpdateVaultProvider,
|
||||
} from "@/server/db/schema";
|
||||
import { createTRPCRouter, withPermission } from "../trpc";
|
||||
|
||||
const findInOrganization = async (
|
||||
vaultProviderId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const provider = await findVaultProviderById(vaultProviderId);
|
||||
if (provider.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this vault provider",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const vaultProviderRouter = createTRPCRouter({
|
||||
create: withPermission("vaultProvider", "create")
|
||||
.input(apiCreateVaultProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await createVaultProvider(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "vaultProvider",
|
||||
resourceId: provider.vaultProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
return { ...provider, config: maskVaultProviderConfig(provider.config) };
|
||||
}),
|
||||
|
||||
update: withPermission("vaultProvider", "update")
|
||||
.input(apiUpdateVaultProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await findInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
const updated = await updateVaultProvider(
|
||||
input.vaultProviderId,
|
||||
input.name,
|
||||
input.config,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "vaultProvider",
|
||||
resourceId: updated.vaultProviderId,
|
||||
resourceName: updated.name,
|
||||
});
|
||||
return { ...updated, config: maskVaultProviderConfig(updated.config) };
|
||||
}),
|
||||
|
||||
remove: withPermission("vaultProvider", "delete")
|
||||
.input(apiRemoveVaultProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "vaultProvider",
|
||||
resourceId: provider.vaultProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
await removeVaultProvider(input.vaultProviderId);
|
||||
return true;
|
||||
}),
|
||||
|
||||
all: withPermission("vaultProvider", "read").query(async ({ ctx }) => {
|
||||
const providers = await findVaultProvidersByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return providers.map((provider) => ({
|
||||
...provider,
|
||||
config: maskVaultProviderConfig(provider.config),
|
||||
}));
|
||||
}),
|
||||
|
||||
one: withPermission("vaultProvider", "read")
|
||||
.input(apiFindOneVaultProvider)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return { ...provider, config: maskVaultProviderConfig(provider.config) };
|
||||
}),
|
||||
|
||||
testConnection: withPermission("vaultProvider", "create")
|
||||
.input(apiTestVaultProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!input.config && !input.vaultProviderId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Provide a config or a vaultProviderId to test",
|
||||
});
|
||||
}
|
||||
|
||||
let config = input.config;
|
||||
if (input.vaultProviderId) {
|
||||
const provider = await findInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
config = config
|
||||
? mergeVaultProviderConfig(config, provider.config)
|
||||
: provider.config;
|
||||
}
|
||||
|
||||
await testVaultProviderConnection(config!);
|
||||
return true;
|
||||
}),
|
||||
|
||||
listSecretNames: withPermission("vaultProvider", "read")
|
||||
.input(apiListVaultSecretNames)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findInOrganization(
|
||||
input.vaultProviderId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return await listVaultProviderSecretNames(provider.config);
|
||||
}),
|
||||
});
|
||||
@ -25,6 +25,7 @@ export const canAccessDockerOverWss = async (
|
||||
serverId?: string | null,
|
||||
serviceId?: string | null,
|
||||
): Promise<boolean> => {
|
||||
// return false;
|
||||
if (!user || !session?.activeOrganizationId) return false;
|
||||
|
||||
const ctx = buildCtx(user, session.activeOrganizationId);
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1097.0",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
|
||||
@ -92,4 +92,5 @@ export type AuditResourceType =
|
||||
| "mount"
|
||||
| "application"
|
||||
| "compose"
|
||||
| "network";
|
||||
| "network"
|
||||
| "vaultProvider";
|
||||
|
||||
@ -42,5 +42,6 @@ export * from "./sso";
|
||||
export * from "./tag";
|
||||
export * from "./user";
|
||||
export * from "./utils";
|
||||
export * from "./vault-provider";
|
||||
export * from "./volume-backups";
|
||||
export * from "./web-server-settings";
|
||||
|
||||
128
packages/server/src/db/schema/vault-provider.ts
Normal file
128
packages/server/src/db/schema/vault-provider.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const vaultProviderType = pgEnum("VaultProviderType", [
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"doppler",
|
||||
"azure",
|
||||
]);
|
||||
|
||||
export const hashicorpVaultConfigSchema = z.object({
|
||||
providerType: z.literal("hashicorp"),
|
||||
url: z.string().url(),
|
||||
token: z.string().min(1),
|
||||
namespace: z.string().optional(),
|
||||
mount: z.string().min(1).default("secret"),
|
||||
});
|
||||
|
||||
export const infisicalVaultConfigSchema = z.object({
|
||||
providerType: z.literal("infisical"),
|
||||
siteUrl: z.string().url().default("https://app.infisical.com"),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
environmentSlug: z.string().min(1),
|
||||
secretPath: z.string().default("/"),
|
||||
});
|
||||
|
||||
export const awsVaultConfigSchema = z.object({
|
||||
providerType: z.literal("aws"),
|
||||
region: z.string().min(1),
|
||||
accessKeyId: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
endpoint: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const dopplerVaultConfigSchema = z.object({
|
||||
providerType: z.literal("doppler"),
|
||||
serviceToken: z.string().min(1),
|
||||
project: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
});
|
||||
|
||||
export const azureVaultConfigSchema = z.object({
|
||||
providerType: z.literal("azure"),
|
||||
vaultUri: z.string().url(),
|
||||
tenantId: z.string().min(1),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
});
|
||||
|
||||
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
hashicorpVaultConfigSchema,
|
||||
infisicalVaultConfigSchema,
|
||||
awsVaultConfigSchema,
|
||||
dopplerVaultConfigSchema,
|
||||
azureVaultConfigSchema,
|
||||
]);
|
||||
|
||||
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
|
||||
|
||||
export const vaultProvider = pgTable(
|
||||
"vault_provider",
|
||||
{
|
||||
vaultProviderId: text("vaultProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: vaultProviderType("providerType").notNull(),
|
||||
config: jsonb("config").$type<VaultProviderConfig>().notNull(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("vault_provider_org_name_idx").on(
|
||||
table.organizationId,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const vaultProviderNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Name can only contain letters, numbers, dashes and underscores",
|
||||
);
|
||||
|
||||
const createSchema = createInsertSchema(vaultProvider);
|
||||
|
||||
export const apiCreateVaultProvider = createSchema.pick({}).extend({
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiUpdateVaultProvider = createSchema.pick({}).extend({
|
||||
vaultProviderId: z.string().min(1),
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiFindOneVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTestVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1).optional(),
|
||||
config: vaultProviderConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const apiListVaultSecretNames = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
@ -51,6 +51,7 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/vault-provider";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
@ -102,6 +103,7 @@ export * from "./utils/docker/compose/volume";
|
||||
export * from "./utils/docker/domain";
|
||||
export * from "./utils/docker/types";
|
||||
export * from "./utils/docker/utils";
|
||||
export * from "./utils/vault";
|
||||
export * from "./utils/filesystem/directory";
|
||||
export * from "./utils/filesystem/ssh";
|
||||
export * from "./utils/git-branch-validation";
|
||||
|
||||
@ -48,6 +48,7 @@ export const statements = {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@ -74,6 +75,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@ -113,6 +115,7 @@ export const ownerRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@ -150,6 +153,7 @@ export const adminRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@ -192,4 +196,6 @@ export const memberRole = ac.newRole({
|
||||
notification: [],
|
||||
tag: ["read"],
|
||||
auditLog: [],
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
});
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from "../utils/docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../utils/vault";
|
||||
import { type Application, findApplicationById } from "./application";
|
||||
import { findDeploymentById } from "./deployment";
|
||||
import type { Environment } from "./environment";
|
||||
@ -215,7 +216,9 @@ const rollbackApplication = async (
|
||||
throw new Error("Full context is required for rollback");
|
||||
}
|
||||
|
||||
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
|
||||
const resolvedContext = await withResolvedVaultRefs(fullContext);
|
||||
|
||||
const rollbackRegistry = resolvedContext.rollbackRegistry ?? undefined;
|
||||
|
||||
// Ensure Docker daemon is authenticated with the rollback registry
|
||||
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||
@ -236,7 +239,7 @@ const rollbackApplication = async (
|
||||
cpuReservation,
|
||||
command,
|
||||
ports,
|
||||
} = fullContext;
|
||||
} = resolvedContext;
|
||||
|
||||
const resources = calculateResources({
|
||||
memoryLimit,
|
||||
@ -248,7 +251,7 @@ const rollbackApplication = async (
|
||||
const volumesMount = generateVolumeMounts(mounts);
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(
|
||||
fullContext as Parameters<typeof resolveServiceNetworks>[0],
|
||||
resolvedContext as Parameters<typeof resolveServiceNetworks>[0],
|
||||
);
|
||||
|
||||
const {
|
||||
@ -261,14 +264,14 @@ const rollbackApplication = async (
|
||||
UpdateConfig,
|
||||
Ulimits,
|
||||
} = generateConfigContainer(
|
||||
fullContext as Parameters<typeof generateConfigContainer>[0],
|
||||
resolvedContext as Parameters<typeof generateConfigContainer>[0],
|
||||
);
|
||||
|
||||
const bindsMount = generateBindMounts(mounts);
|
||||
const envVariables = prepareEnvironmentVariables(
|
||||
env,
|
||||
fullContext.environment.project.env,
|
||||
fullContext.environment.env,
|
||||
resolvedContext.environment.project.env,
|
||||
resolvedContext.environment.env,
|
||||
);
|
||||
|
||||
let rollbackImage = image;
|
||||
|
||||
200
packages/server/src/services/vault-provider.ts
Normal file
200
packages/server/src/services/vault-provider.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateVaultProvider,
|
||||
type VaultProviderConfig,
|
||||
vaultProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { getVaultClient } from "@dokploy/server/utils/vault";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type VaultProvider = typeof vaultProvider.$inferSelect;
|
||||
|
||||
export const VAULT_SECRET_MASK = "********";
|
||||
|
||||
const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
|
||||
{
|
||||
hashicorp: ["token"],
|
||||
infisical: ["clientSecret"],
|
||||
aws: ["secretAccessKey"],
|
||||
doppler: ["serviceToken"],
|
||||
azure: ["clientSecret"],
|
||||
};
|
||||
|
||||
export const maskVaultProviderConfig = (
|
||||
config: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const masked: Record<string, unknown> = { ...config };
|
||||
for (const field of SENSITIVE_FIELDS[config.providerType]) {
|
||||
if (masked[field]) {
|
||||
masked[field] = VAULT_SECRET_MASK;
|
||||
}
|
||||
}
|
||||
return masked as VaultProviderConfig;
|
||||
};
|
||||
|
||||
export const mergeVaultProviderConfig = (
|
||||
incoming: VaultProviderConfig,
|
||||
existing: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const merged: Record<string, unknown> = { ...incoming };
|
||||
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
|
||||
if (merged[field] === VAULT_SECRET_MASK) {
|
||||
if (incoming.providerType !== existing.providerType) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Credentials must be re-entered when changing the provider type",
|
||||
});
|
||||
}
|
||||
merged[field] = (existing as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
return merged as VaultProviderConfig;
|
||||
};
|
||||
|
||||
const isUniqueNameViolation = (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message.includes("vault_provider_org_name_idx");
|
||||
|
||||
export const createVaultProvider = async (
|
||||
input: z.infer<typeof apiCreateVaultProvider>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
try {
|
||||
const newProvider = await db
|
||||
.insert(vaultProvider)
|
||||
.values({
|
||||
name: input.name,
|
||||
providerType: input.config.providerType,
|
||||
config: input.config,
|
||||
organizationId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newProvider) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the vault provider",
|
||||
});
|
||||
}
|
||||
return newProvider;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${input.name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const findVaultProviderById = async (vaultProviderId: string) => {
|
||||
const provider = await db.query.vaultProvider.findFirst({
|
||||
where: eq(vaultProvider.vaultProviderId, vaultProviderId),
|
||||
});
|
||||
if (!provider) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProvidersByOrganizationId = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.vaultProvider.findMany({
|
||||
where: eq(vaultProvider.organizationId, organizationId),
|
||||
orderBy: (providers, { asc }) => [asc(providers.name)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateVaultProvider = async (
|
||||
vaultProviderId: string,
|
||||
name: string,
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const existing = await findVaultProviderById(vaultProviderId);
|
||||
const mergedConfig = mergeVaultProviderConfig(config, existing.config);
|
||||
|
||||
try {
|
||||
const updated = await db
|
||||
.update(vaultProvider)
|
||||
.set({
|
||||
name,
|
||||
providerType: mergedConfig.providerType,
|
||||
config: mergedConfig,
|
||||
})
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error updating the vault provider",
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeVaultProvider = async (vaultProviderId: string) => {
|
||||
const removed = await db
|
||||
.delete(vaultProvider)
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!removed) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export const testVaultProviderConnection = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
try {
|
||||
await client.testConnection(config);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error connecting to the vault provider",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listVaultProviderSecretNames = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
if (!client.listSecretNames) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await client.listSecretNames(config);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@ -9,13 +9,15 @@ import {
|
||||
getEnvironmentVariablesObject,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
} from "../docker/utils";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type ComposeNested = InferResultType<
|
||||
"compose",
|
||||
{ environment: { with: { project: true } }; mounts: true; domains: true }
|
||||
>;
|
||||
|
||||
export const getBuildComposeCommand = async (compose: ComposeNested) => {
|
||||
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const { sourceType, appName, mounts, composeType, domains } = compose;
|
||||
const command = createCommand(compose);
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
import { getDockerCommand } from "./docker-file";
|
||||
import { getHerokuCommand } from "./heroku";
|
||||
import { getNixpacksCommand } from "./nixpacks";
|
||||
@ -38,7 +39,8 @@ export type ApplicationNested = InferResultType<
|
||||
}
|
||||
>;
|
||||
|
||||
export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
export const getBuildCommand = async (rawApplication: ApplicationNested) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
let command = "";
|
||||
|
||||
if (application.sourceType !== "docker") {
|
||||
@ -77,8 +79,9 @@ export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
};
|
||||
|
||||
export const mechanizeDockerContainer = async (
|
||||
application: ApplicationNested,
|
||||
rawApplication: ApplicationNested,
|
||||
) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type LibsqlNested = InferResultType<
|
||||
"libsql",
|
||||
@ -18,7 +19,8 @@ export type LibsqlNested = InferResultType<
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
>;
|
||||
export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
export const buildLibsql = async (rawLibsql: LibsqlNested) => {
|
||||
const libsql = await withResolvedVaultRefs(rawLibsql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@ -10,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MariadbNested = InferResultType<
|
||||
"mariadb",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
export const buildMariadb = async (rawMariadb: MariadbNested) => {
|
||||
const mariadb = await withResolvedVaultRefs(rawMariadb);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@ -10,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MongoNested = InferResultType<
|
||||
"mongo",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMongo = async (mongo: MongoNested) => {
|
||||
export const buildMongo = async (rawMongo: MongoNested) => {
|
||||
const mongo = await withResolvedVaultRefs(rawMongo);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@ -10,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MysqlNested = InferResultType<
|
||||
"mysql",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMysql = async (mysql: MysqlNested) => {
|
||||
export const buildMysql = async (rawMysql: MysqlNested) => {
|
||||
const mysql = await withResolvedVaultRefs(rawMysql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@ -10,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type PostgresNested = InferResultType<
|
||||
"postgres",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
export const buildPostgres = async (rawPostgres: PostgresNested) => {
|
||||
const postgres = await withResolvedVaultRefs(rawPostgres);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@ -10,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type RedisNested = InferResultType<
|
||||
"redis",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildRedis = async (redis: RedisNested) => {
|
||||
export const buildRedis = async (rawRedis: RedisNested) => {
|
||||
const redis = await withResolvedVaultRefs(rawRedis);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
|
||||
@ -401,6 +401,13 @@ export const prepareEnvironmentVariables = (
|
||||
projectEnv?: string | null,
|
||||
environmentEnv?: string | null,
|
||||
) => {
|
||||
for (const source of [serviceEnv, projectEnv, environmentEnv]) {
|
||||
if (source?.includes("${{vault.")) {
|
||||
throw new Error(
|
||||
"Unresolved vault reference: call withResolvedVaultRefs() on the entity before preparing environment variables",
|
||||
);
|
||||
}
|
||||
}
|
||||
const projectVars = parse(projectEnv ?? "");
|
||||
const environmentVars = parse(environmentEnv ?? "");
|
||||
const serviceVars = parse(serviceEnv ?? "");
|
||||
|
||||
107
packages/server/src/utils/vault/aws.ts
Normal file
107
packages/server/src/utils/vault/aws.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import {
|
||||
GetSecretValueCommand,
|
||||
ListSecretsCommand,
|
||||
SecretsManagerClient,
|
||||
} from "@aws-sdk/client-secrets-manager";
|
||||
import type { awsVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import type { VaultClient } from "./types";
|
||||
|
||||
type AwsConfig = z.infer<typeof awsVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
if (ref.startsWith("arn:")) {
|
||||
throw new Error(
|
||||
`Invalid AWS Secrets Manager reference "${ref}": use the secret name, not the ARN`,
|
||||
);
|
||||
}
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return { secretId: ref, field: null };
|
||||
}
|
||||
return {
|
||||
secretId: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const createClient = (config: AwsConfig) =>
|
||||
new SecretsManagerClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
...(config.endpoint && { endpoint: config.endpoint }),
|
||||
});
|
||||
|
||||
export const awsClient: VaultClient<AwsConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const client = createClient(config);
|
||||
const secretIds = [...new Set(refs.map((ref) => parseRef(ref).secretId))];
|
||||
|
||||
const secretStrings = new Map<string, string>();
|
||||
await Promise.all(
|
||||
secretIds.map(async (secretId) => {
|
||||
const response = await client.send(
|
||||
new GetSecretValueCommand({ SecretId: secretId }),
|
||||
);
|
||||
if (response.SecretString === undefined) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" has no string value (binary secrets are not supported)`,
|
||||
);
|
||||
}
|
||||
secretStrings.set(secretId, response.SecretString);
|
||||
}),
|
||||
);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
const { secretId, field } = parseRef(ref);
|
||||
const secretString = secretStrings.get(secretId) as string;
|
||||
if (field === null) {
|
||||
result[ref] = secretString;
|
||||
continue;
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(secretString);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" is not JSON, cannot extract field "${field}"`,
|
||||
);
|
||||
}
|
||||
const value = parsed[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: field "${field}" not found in secret "${secretId}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const client = createClient(config);
|
||||
await client.send(new ListSecretsCommand({ MaxResults: 1 }));
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const client = createClient(config);
|
||||
const names: string[] = [];
|
||||
let nextToken: string | undefined;
|
||||
do {
|
||||
const response = await client.send(
|
||||
new ListSecretsCommand({ MaxResults: 100, NextToken: nextToken }),
|
||||
);
|
||||
for (const secret of response.SecretList ?? []) {
|
||||
if (secret.Name) {
|
||||
names.push(secret.Name);
|
||||
}
|
||||
}
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken && names.length < 500);
|
||||
return names;
|
||||
},
|
||||
};
|
||||
123
packages/server/src/utils/vault/azure.ts
Normal file
123
packages/server/src/utils/vault/azure.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import type { azureVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type AzureConfig = z.infer<typeof azureVaultConfigSchema>;
|
||||
|
||||
const API_VERSION = "7.4";
|
||||
|
||||
const baseUrl = (config: AzureConfig) => config.vaultUri.replace(/\/+$/, "");
|
||||
|
||||
const getAccessToken = async (config: AzureConfig) => {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
scope: "https://vault.azure.net/.default",
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`https://login.microsoftonline.com/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { error_description?: string };
|
||||
detail = (body.error_description ?? "").split("\n")[0] ?? "";
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Azure Key Vault: authentication failed (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token?: string };
|
||||
if (!data.access_token) {
|
||||
throw new Error("Azure Key Vault: no access token returned");
|
||||
}
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
const readSecret = async (
|
||||
config: AzureConfig,
|
||||
token: string,
|
||||
name: string,
|
||||
) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" not found`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: failed to read secret "${name}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { value?: string };
|
||||
if (data.value === undefined) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" has no value`);
|
||||
}
|
||||
return data.value;
|
||||
};
|
||||
|
||||
export const azureClient: VaultClient<AzureConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const token = await getAccessToken(config);
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
refs.map(async (ref) => {
|
||||
result[ref] = await readSecret(config, token, ref);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=1`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: cannot list secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const names: string[] = [];
|
||||
let url: string | null =
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=25`;
|
||||
|
||||
while (url && names.length < 200) {
|
||||
const response = await vaultFetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
break;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
value?: { id: string }[];
|
||||
nextLink?: string | null;
|
||||
};
|
||||
for (const item of data.value ?? []) {
|
||||
const name = item.id.split("/").pop();
|
||||
if (name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
url = data.nextLink ?? null;
|
||||
}
|
||||
return names.slice(0, 200);
|
||||
},
|
||||
};
|
||||
61
packages/server/src/utils/vault/doppler.ts
Normal file
61
packages/server/src/utils/vault/doppler.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { dopplerVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type DopplerConfig = z.infer<typeof dopplerVaultConfigSchema>;
|
||||
|
||||
const downloadUrl = (config: DopplerConfig) => {
|
||||
const params = new URLSearchParams({ format: "json" });
|
||||
if (config.project) {
|
||||
params.set("project", config.project);
|
||||
}
|
||||
if (config.config) {
|
||||
params.set("config", config.config);
|
||||
}
|
||||
return `https://api.doppler.com/v3/configs/config/secrets/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const downloadSecrets = async (config: DopplerConfig) => {
|
||||
const response = await vaultFetch(downloadUrl(config), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.serviceToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { messages?: string[] };
|
||||
detail = (body.messages ?? []).join(", ");
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Doppler: failed to fetch secrets (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as Record<string, string>;
|
||||
};
|
||||
|
||||
export const dopplerClient: VaultClient<DopplerConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(`Doppler: secret "${ref}" not found in this config`);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await downloadSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
140
packages/server/src/utils/vault/hashicorp.ts
Normal file
140
packages/server/src/utils/vault/hashicorp.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import type { hashicorpVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type HashicorpConfig = z.infer<typeof hashicorpVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex <= 0 || separatorIndex === ref.length - 1) {
|
||||
throw new Error(
|
||||
`Invalid HashiCorp Vault reference "${ref}": expected format <path>:<field> (e.g. myapp/prod:DB_PASSWORD)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const buildHeaders = (config: HashicorpConfig) => {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Vault-Token": config.token,
|
||||
};
|
||||
if (config.namespace) {
|
||||
headers["X-Vault-Namespace"] = config.namespace;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const baseUrl = (config: HashicorpConfig) => config.url.replace(/\/+$/, "");
|
||||
|
||||
const encodePath = (path: string) =>
|
||||
path.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
const readSecret = async (config: HashicorpConfig, path: string) => {
|
||||
const url = `${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/data/${encodePath(path)}`;
|
||||
const response = await vaultFetch(url, { headers: buildHeaders(config) });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: failed to read secret at "${path}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: { data?: Record<string, unknown> };
|
||||
};
|
||||
return body.data?.data ?? {};
|
||||
};
|
||||
|
||||
export const hashicorpClient: VaultClient<HashicorpConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const byPath = new Map<string, string[]>();
|
||||
for (const ref of refs) {
|
||||
const { path } = parseRef(ref);
|
||||
byPath.set(path, [...(byPath.get(path) ?? []), ref]);
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
[...byPath.entries()].map(async ([path, pathRefs]) => {
|
||||
const data = await readSecret(config, path);
|
||||
for (const ref of pathRefs) {
|
||||
const { field } = parseRef(ref);
|
||||
const value = data[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: field "${field}" not found in secret "${path}"`,
|
||||
);
|
||||
}
|
||||
result[ref] =
|
||||
typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/auth/token/lookup-self`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: token validation failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const MAX_DEPTH = 4;
|
||||
const MAX_ENTRIES = 200;
|
||||
const names: string[] = [];
|
||||
|
||||
const listKeys = async (path: string) => {
|
||||
const cleanPath = path.replace(/\/+$/, "");
|
||||
const suffix = cleanPath ? `/${encodePath(cleanPath)}` : "";
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/metadata${suffix}?list=true`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const body = (await response.json()) as { data?: { keys?: string[] } };
|
||||
return body.data?.keys ?? [];
|
||||
};
|
||||
|
||||
const walk = async (path: string, depth: number) => {
|
||||
if (depth > MAX_DEPTH || names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
for (const key of await listKeys(path)) {
|
||||
if (names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
const fullPath = `${path}${key}`;
|
||||
if (key.endsWith("/")) {
|
||||
await walk(fullPath, depth + 1);
|
||||
continue;
|
||||
}
|
||||
const fields = await readSecret(config, fullPath).catch(
|
||||
() => ({}) as Record<string, unknown>,
|
||||
);
|
||||
const fieldNames = Object.keys(fields);
|
||||
if (fieldNames.length === 0) {
|
||||
names.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
for (const field of fieldNames) {
|
||||
names.push(`${fullPath}:${field}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk("", 0);
|
||||
return names.slice(0, MAX_ENTRIES);
|
||||
},
|
||||
};
|
||||
BIN
packages/server/src/utils/vault/index.ts
Normal file
BIN
packages/server/src/utils/vault/index.ts
Normal file
Binary file not shown.
87
packages/server/src/utils/vault/infisical.ts
Normal file
87
packages/server/src/utils/vault/infisical.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { infisicalVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type InfisicalConfig = z.infer<typeof infisicalVaultConfigSchema>;
|
||||
|
||||
const baseUrl = (config: InfisicalConfig) => config.siteUrl.replace(/\/+$/, "");
|
||||
|
||||
const login = async (config: InfisicalConfig) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v1/auth/universal-auth/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: authentication failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { accessToken?: string };
|
||||
if (!body.accessToken) {
|
||||
throw new Error("Infisical: no access token returned");
|
||||
}
|
||||
return body.accessToken;
|
||||
};
|
||||
|
||||
const fetchSecrets = async (config: InfisicalConfig) => {
|
||||
const accessToken = await login(config);
|
||||
const params = new URLSearchParams({
|
||||
workspaceId: config.projectId,
|
||||
environment: config.environmentSlug,
|
||||
secretPath: config.secretPath,
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: failed to fetch secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
secrets?: { secretKey: string; secretValue: string }[];
|
||||
};
|
||||
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const secret of body.secrets ?? []) {
|
||||
secrets[secret.secretKey] = secret.secretValue;
|
||||
}
|
||||
return secrets;
|
||||
};
|
||||
|
||||
export const infisicalClient: VaultClient<InfisicalConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(
|
||||
`Infisical: secret "${ref}" not found in environment "${config.environmentSlug}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await fetchSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
18
packages/server/src/utils/vault/types.ts
Normal file
18
packages/server/src/utils/vault/types.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { VaultProviderConfig } from "@dokploy/server/db/schema";
|
||||
|
||||
export interface VaultClient<
|
||||
C extends VaultProviderConfig = VaultProviderConfig,
|
||||
> {
|
||||
getSecrets(config: C, refs: string[]): Promise<Record<string, string>>;
|
||||
testConnection(config: C): Promise<void>;
|
||||
listSecretNames?(config: C): Promise<string[]>;
|
||||
}
|
||||
|
||||
export const VAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const vaultFetch = async (url: string, init: RequestInit = {}) => {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(VAULT_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
};
|
||||
274
pnpm-lock.yaml
274
pnpm-lock.yaml
@ -582,6 +582,9 @@ importers:
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30(zod@4.3.6)
|
||||
'@aws-sdk/client-secrets-manager':
|
||||
specifier: ^3.1097.0
|
||||
version: 3.1097.0
|
||||
'@better-auth/api-key':
|
||||
specifier: 1.6.23
|
||||
version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(1ed75fb9bf08f6ba6496d8d3414d7193))(better-call@1.3.7(zod@4.3.6))
|
||||
@ -871,6 +874,70 @@ packages:
|
||||
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@aws-sdk/client-secrets-manager@3.1097.0':
|
||||
resolution: {integrity: sha512-EAyknLfabMWA1kAVgcBO6iTYBWFVnoX8WRrKEbPf6npNZUKv7oJoZBK+I0OvRK0o3P0ynbey64pDlspWXB9dag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.977.2':
|
||||
resolution: {integrity: sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.63':
|
||||
resolution: {integrity: sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.65':
|
||||
resolution: {integrity: sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.8':
|
||||
resolution: {integrity: sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.70':
|
||||
resolution: {integrity: sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.74':
|
||||
resolution: {integrity: sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.63':
|
||||
resolution: {integrity: sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.7':
|
||||
resolution: {integrity: sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.69':
|
||||
resolution: {integrity: sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.37':
|
||||
resolution: {integrity: sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.42':
|
||||
resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1097.0':
|
||||
resolution: {integrity: sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.974.2':
|
||||
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.37':
|
||||
resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0':
|
||||
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -3787,6 +3854,30 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@smithy/core@3.31.0':
|
||||
resolution: {integrity: sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.15':
|
||||
resolution: {integrity: sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.12':
|
||||
resolution: {integrity: sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.9.12':
|
||||
resolution: {integrity: sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.6.11':
|
||||
resolution: {integrity: sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.16.1':
|
||||
resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
@ -4683,6 +4774,9 @@ packages:
|
||||
bottleneck@2.19.5:
|
||||
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
|
||||
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
boxen@7.1.1:
|
||||
resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==}
|
||||
engines: {node: '>=14.16'}
|
||||
@ -8973,6 +9067,151 @@ snapshots:
|
||||
escape-html: 1.0.3
|
||||
xpath: 0.0.32
|
||||
|
||||
'@aws-sdk/client-secrets-manager@3.1097.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/credential-provider-node': 3.972.74
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/fetch-http-handler': 5.6.12
|
||||
'@smithy/node-http-handler': 4.9.12
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.977.2':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@aws-sdk/xml-builder': 3.972.37
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/signature-v4': 5.6.11
|
||||
'@smithy/types': 4.16.1
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.63':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.65':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/fetch-http-handler': 5.6.12
|
||||
'@smithy/node-http-handler': 4.9.12
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.8':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/credential-provider-env': 3.972.63
|
||||
'@aws-sdk/credential-provider-http': 3.972.65
|
||||
'@aws-sdk/credential-provider-login': 3.972.70
|
||||
'@aws-sdk/credential-provider-process': 3.972.63
|
||||
'@aws-sdk/credential-provider-sso': 3.973.7
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.69
|
||||
'@aws-sdk/nested-clients': 3.997.37
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/credential-provider-imds': 4.4.15
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/nested-clients': 3.997.37
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.74':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.63
|
||||
'@aws-sdk/credential-provider-http': 3.972.65
|
||||
'@aws-sdk/credential-provider-ini': 3.973.8
|
||||
'@aws-sdk/credential-provider-process': 3.972.63
|
||||
'@aws-sdk/credential-provider-sso': 3.973.7
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.69
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/credential-provider-imds': 4.4.15
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.63':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.7':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/nested-clients': 3.997.37
|
||||
'@aws-sdk/token-providers': 3.1097.0
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.69':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/nested-clients': 3.997.37
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.37':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.42
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/fetch-http-handler': 5.6.12
|
||||
'@smithy/node-http-handler': 4.9.12
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.42':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/signature-v4': 5.6.11
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1097.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.2
|
||||
'@aws-sdk/nested-clients': 3.997.37
|
||||
'@aws-sdk/types': 3.974.2
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.974.2':
|
||||
dependencies:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.37':
|
||||
dependencies:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0': {}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
@ -12262,6 +12501,39 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@smithy/core@3.31.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.4.15':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.6.12':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.9.12':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.6.11':
|
||||
dependencies:
|
||||
'@smithy/core': 3.31.0
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.16.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
@ -13491,6 +13763,8 @@ snapshots:
|
||||
|
||||
bottleneck@2.19.5: {}
|
||||
|
||||
bowser@2.14.1: {}
|
||||
|
||||
boxen@7.1.1:
|
||||
dependencies:
|
||||
ansi-align: 3.0.1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user