mirror of
https://github.com/dokploy/dokploy.git
synced 2026-06-15 11:59:49 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 549b124fcd | |||
| 0c5da0b36f | |||
| ee18724dd7 | |||
| 1ae9b4025c | |||
| 6e342ee2f2 | |||
| ef0cf9bd02 | |||
| 8d88a34a64 | |||
| a50f958a6f | |||
| 1fdbe87d84 | |||
| 67278d8783 | |||
| aff200f84f | |||
| 558d809871 | |||
| f8fcf68909 | |||
| 7a568aadac | |||
| 63e33a29cc | |||
| 754774ea02 | |||
| a714e0f83f | |||
| 9f10f0f4e9 | |||
| 5bc870dc2d | |||
| 299950a323 |
@@ -0,0 +1,21 @@
|
||||
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
require-description: true
|
||||
min-account-age: 5
|
||||
+8403
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
|
||||
HOSTINGER_API_KEY=
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Isolate the command builder from the compose-file I/O performed by
|
||||
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
const baseCompose = {
|
||||
appName: "my-app",
|
||||
sourceType: "raw",
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
composeType: "stack",
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
env: "",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
||||
|
||||
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
||||
// clears the environment except for the vars listed explicitly. HOME must be
|
||||
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
||||
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
||||
// and private-registry images fail to pull.
|
||||
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
||||
it("preserves HOME for swarm stack deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "stack",
|
||||
});
|
||||
|
||||
expect(command).toContain("stack deploy");
|
||||
expect(command).toContain("--with-registry-auth");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
|
||||
it("preserves HOME for docker compose deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "docker-compose",
|
||||
});
|
||||
|
||||
expect(command).toContain("compose -p my-app");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ describe("Host rule format regression tests", () => {
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("createDomainLabels", () => {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@@ -104,51 +103,6 @@ describe("createDomainLabels", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "websecure");
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls=true",
|
||||
);
|
||||
// no cert resolver should be set when relying on a default/custom cert
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add tls=true for certificateType none on web entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "web");
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-web.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "websecure-custom",
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
noneDomain,
|
||||
"websecure-custom",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different ports correctly", async () => {
|
||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { shouldDeploy } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("shouldDeploy", () => {
|
||||
it("should deploy when no watch paths are configured", () => {
|
||||
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should deploy when watch paths match modified files", () => {
|
||||
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should not deploy when watch paths do not match", () => {
|
||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when modified files contain non-string values", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
).not.toThrow();
|
||||
expect(
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not throw when modified files are undefined or null", () => {
|
||||
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
||||
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
||||
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
||||
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when every modified file is non-string", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
||||
).not.toThrow();
|
||||
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,369 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
canEditDeployGitSource,
|
||||
getAccessibleGitProviderIds,
|
||||
} from "@dokploy/server/services/git-provider";
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
query: {
|
||||
gitProvider: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
member: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
||||
|
||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: mockHasValidLicense,
|
||||
}));
|
||||
|
||||
const ORG_ID = "org-1";
|
||||
const USER_OWNER = "user-owner";
|
||||
const USER_ADMIN = "user-admin";
|
||||
const USER_MEMBER = "user-member";
|
||||
const USER_MEMBER_2 = "user-member-2";
|
||||
|
||||
const providerOwned = {
|
||||
gitProviderId: "gp-owned",
|
||||
userId: USER_MEMBER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
const providerShared = {
|
||||
gitProviderId: "gp-shared",
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
};
|
||||
const providerPrivate = {
|
||||
gitProviderId: "gp-private",
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
const providerOtherMember = {
|
||||
gitProviderId: "gp-other",
|
||||
userId: USER_MEMBER_2,
|
||||
sharedWithOrganization: false,
|
||||
};
|
||||
|
||||
const allProviders = [
|
||||
providerOwned,
|
||||
providerShared,
|
||||
providerPrivate,
|
||||
providerOtherMember,
|
||||
];
|
||||
|
||||
function session(userId: string) {
|
||||
return { userId, activeOrganizationId: ORG_ID };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("getAccessibleGitProviderIds", () => {
|
||||
describe("owner", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "owner",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all org providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||
});
|
||||
|
||||
it("includes providers owned by other members", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "admin",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all org providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||
});
|
||||
|
||||
it("includes providers owned by other members — fixes issue #4469", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member without enterprise license", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
mockHasValidLicense.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("can access their own provider", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("can access shared providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access private providers of other users even if assigned (no license)", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot access providers of other members", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member with enterprise license", () => {
|
||||
beforeEach(() => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("can access provider explicitly assigned to them", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access provider not assigned and not shared", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
|
||||
it("can access shared provider even without explicit assignment", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("can access own provider regardless of assignments", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot access provider of other member even with license but no assignment", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member with no member record", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue(null);
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("only returns own providers and shared ones", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enterprise license — member assigned to a provider they do not own", () => {
|
||||
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
|
||||
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||
});
|
||||
|
||||
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "member",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("empty org", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
|
||||
mockDb.query.member.findFirst.mockResolvedValue({
|
||||
role: "admin",
|
||||
accessedGitProviders: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty set when org has no providers", async () => {
|
||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||
expect(ids.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEditDeployGitSource", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasValidLicense.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("owner", () => {
|
||||
it("can edit deploy using any provider", async () => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_OWNER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
|
||||
});
|
||||
|
||||
it("cannot edit deploy using owner's private provider (not shared)", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("can edit deploy using a provider shared with the org", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerShared.gitProviderId,
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can edit deploy using their own provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_ADMIN,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
"gp-admin-owned",
|
||||
session(USER_ADMIN),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("member", () => {
|
||||
beforeEach(() => {
|
||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
|
||||
});
|
||||
|
||||
it("can edit deploy using their own provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_MEMBER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerOwned.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("can edit deploy using a provider shared with the org", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: true,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerShared.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
|
||||
// This is the key case: enterprise, provider del owner, no compartido,
|
||||
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_OWNER,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerPrivate.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot edit deploy using another member's private provider", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||
userId: USER_MEMBER_2,
|
||||
sharedWithOrganization: false,
|
||||
});
|
||||
const result = await canEditDeployGitSource(
|
||||
providerOtherMember.gitProviderId,
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false if provider does not exist", async () => {
|
||||
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
|
||||
const result = await canEditDeployGitSource(
|
||||
"nonexistent-id",
|
||||
session(USER_MEMBER),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("owner and admin bypass enterprise resources", () => {
|
||||
describe("static roles bypass enterprise resources", () => {
|
||||
it("owner bypasses deployment.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
@@ -73,8 +73,15 @@ describe("owner and admin bypass enterprise resources", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
it("member bypasses schedule.delete", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { schedule: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, {
|
||||
deployment: ["read"],
|
||||
@@ -85,55 +92,6 @@ describe("owner and admin bypass enterprise resources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
|
||||
it("member is denied registry.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { registry: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied certificate.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { certificate: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied destination.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { destination: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied notification.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { notification: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied auditLog.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { auditLog: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied server.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied registry.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { registry: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
it("owner passes project.create", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import type { ApplicationNested, Domain } from "@dokploy/server";
|
||||
import {
|
||||
buildForwardAuthEnv,
|
||||
createRouterConfig,
|
||||
deriveBaseDomain,
|
||||
deriveCookieSecret,
|
||||
forwardAuthCallbackUrl,
|
||||
forwardAuthMiddlewareName,
|
||||
} from "@dokploy/server";
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
const app = {
|
||||
appName: "my-app",
|
||||
redirects: [],
|
||||
security: [],
|
||||
} as unknown as ApplicationNested;
|
||||
|
||||
const baseDomain: Domain = {
|
||||
applicationId: "app-1",
|
||||
certificateType: "none",
|
||||
createdAt: "",
|
||||
domainId: "domain-1",
|
||||
host: "app.example.com",
|
||||
https: false,
|
||||
path: null,
|
||||
port: 3000,
|
||||
customEntrypoint: null,
|
||||
serviceName: "",
|
||||
composeId: "",
|
||||
customCertResolver: null,
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 7,
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
describe("forwardAuthMiddlewareName", () => {
|
||||
test("is stable and unique per app + uniqueConfigKey", () => {
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||
"forward-auth-my-app-7",
|
||||
);
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
|
||||
forwardAuthMiddlewareName("my-app", 8),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRouterConfig forward-auth wiring", () => {
|
||||
test("does NOT add forward-auth middleware when no provider is linked", async () => {
|
||||
const config = await createRouterConfig(app, baseDomain, "websecure");
|
||||
expect(config.middlewares).not.toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
|
||||
test("adds forward-auth middleware when a provider is linked", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
forwardAuthEnabled: true,
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "websecure");
|
||||
expect(config.middlewares).toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
|
||||
test("forward-auth runs before custom domain middlewares", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
forwardAuthEnabled: true,
|
||||
middlewares: ["rate-limit@file"],
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "websecure");
|
||||
const forwardAuthIdx = config.middlewares?.indexOf(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
const customIdx = config.middlewares?.indexOf("rate-limit@file");
|
||||
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
|
||||
});
|
||||
|
||||
test("redirect-only web router does not get the forward-auth middleware", async () => {
|
||||
const domain: Domain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
forwardAuthEnabled: true,
|
||||
};
|
||||
const config = await createRouterConfig(app, domain, "web");
|
||||
expect(config.middlewares).toContain("redirect-to-https");
|
||||
expect(config.middlewares).not.toContain(
|
||||
forwardAuthMiddlewareName("my-app", 7),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForwardAuthEnv", () => {
|
||||
const baseOptions = {
|
||||
oidc: {
|
||||
clientId: "client-123",
|
||||
clientSecret: "secret-xyz",
|
||||
issuer: "https://idp.example.com",
|
||||
},
|
||||
cookieSecret: "cookie-secret-value",
|
||||
authDomain: "auth.acme.com",
|
||||
baseDomain: ".acme.com",
|
||||
authDomainHttps: true,
|
||||
};
|
||||
|
||||
test("emits the required oauth2-proxy OIDC env vars", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
|
||||
);
|
||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
|
||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
|
||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
|
||||
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
|
||||
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
|
||||
});
|
||||
|
||||
test("uses the central auth domain for the single fixed callback", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
|
||||
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
|
||||
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
|
||||
});
|
||||
|
||||
test("matches cookie Secure flag and callback scheme to https setting", () => {
|
||||
const https = buildForwardAuthEnv(baseOptions);
|
||||
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
|
||||
|
||||
const http = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
authDomainHttps: false,
|
||||
});
|
||||
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
|
||||
expect(http).toContain(
|
||||
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
|
||||
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain(
|
||||
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
|
||||
);
|
||||
});
|
||||
|
||||
test("defaults to any authenticated user and standard scopes", () => {
|
||||
const env = buildForwardAuthEnv(baseOptions);
|
||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
|
||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
|
||||
});
|
||||
|
||||
test("honors custom scopes and email domains", () => {
|
||||
const env = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
|
||||
emailDomains: ["acme.com", "corp.com"],
|
||||
});
|
||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
|
||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
|
||||
});
|
||||
|
||||
test("sets skip-discovery flag only when requested", () => {
|
||||
const withoutSkip = buildForwardAuthEnv(baseOptions);
|
||||
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||
|
||||
const withSkip = buildForwardAuthEnv({
|
||||
...baseOptions,
|
||||
oidc: { ...baseOptions.oidc, skipDiscovery: true },
|
||||
});
|
||||
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBaseDomain", () => {
|
||||
test("strips the auth subdomain to the shared base", () => {
|
||||
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
|
||||
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
|
||||
});
|
||||
|
||||
test("keeps a two-label apex as the base", () => {
|
||||
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forwardAuthCallbackUrl", () => {
|
||||
test("builds the single IdP callback per scheme", () => {
|
||||
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
|
||||
"https://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
|
||||
"http://auth.acme.com/oauth2/callback",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveCookieSecret", () => {
|
||||
beforeAll(() => {
|
||||
process.env.BETTER_AUTH_SECRET = "test-root-secret";
|
||||
});
|
||||
|
||||
test("is deterministic for the same salt (survives service updates)", () => {
|
||||
expect(deriveCookieSecret(".acme.com")).toBe(
|
||||
deriveCookieSecret(".acme.com"),
|
||||
);
|
||||
});
|
||||
|
||||
test("differs per salt", () => {
|
||||
expect(deriveCookieSecret(".acme.com")).not.toBe(
|
||||
deriveCookieSecret(".other.com"),
|
||||
);
|
||||
});
|
||||
|
||||
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
|
||||
const secret = deriveCookieSecret(".acme.com");
|
||||
expect(Buffer.from(secret, "hex")).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -65,8 +65,6 @@ const baseSettings: WebServerSettings = {
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
remoteServersOnly: false,
|
||||
enforceSSO: false,
|
||||
createdAt: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -148,7 +148,6 @@ const baseDomain: Domain = {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -78,20 +78,4 @@ describe("readValidDirectory (path traversal)", () => {
|
||||
it("returns false for empty string (resolves to cwd)", () => {
|
||||
expect(readValidDirectory("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for Next.js dynamic route paths with square brackets", () => {
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-33
@@ -16,17 +16,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const optionalNumber = z
|
||||
.union([z.string(), z.number()])
|
||||
.transform((val) => (val === "" ? undefined : Number(val)))
|
||||
.optional();
|
||||
|
||||
export const healthCheckFormSchema = z.object({
|
||||
Test: z.array(z.string()).optional(),
|
||||
Interval: optionalNumber,
|
||||
Timeout: optionalNumber,
|
||||
StartPeriod: optionalNumber,
|
||||
Retries: optionalNumber,
|
||||
Interval: z.coerce.number().optional(),
|
||||
Timeout: z.coerce.number().optional(),
|
||||
StartPeriod: z.coerce.number().optional(),
|
||||
Retries: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
@@ -200,12 +195,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -222,12 +212,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Maximum time to wait for health check response
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -244,12 +229,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
Initial grace period before health checks begin
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="10000000000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -267,12 +247,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
unhealthy
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
<Input type="number" placeholder="3" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>Memory Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>Memory Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>CPU Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel>CPU Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
|
||||
@@ -806,7 +806,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
<FormLabel>Middlewares</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: haveValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: status } = api.forwardAuth.status.useQuery(
|
||||
{ domainId },
|
||||
{ enabled: isOpen },
|
||||
);
|
||||
|
||||
const { mutateAsync: enable, isPending: isEnabling } =
|
||||
api.forwardAuth.enable.useMutation();
|
||||
const { mutateAsync: disable, isPending: isDisabling } =
|
||||
api.forwardAuth.disable.useMutation();
|
||||
|
||||
if (!haveValidLicense) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEnabled = !!status?.enabled;
|
||||
const isPending = isEnabling || isDisabling;
|
||||
|
||||
const refresh = async () => {
|
||||
await utils.forwardAuth.status.invalidate({ domainId });
|
||||
await utils.domain.byApplicationId.invalidate({ applicationId });
|
||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||
};
|
||||
|
||||
const handleToggle = async (next: boolean) => {
|
||||
try {
|
||||
if (next) {
|
||||
await enable({ domainId });
|
||||
toast.success("SSO authentication enabled for this domain");
|
||||
} else {
|
||||
await disable({ domainId });
|
||||
toast.success("SSO authentication disabled for this domain");
|
||||
}
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error updating SSO authentication",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-emerald-500/10"
|
||||
title="SSO authentication"
|
||||
>
|
||||
<ShieldCheck
|
||||
className={`size-4 ${
|
||||
isEnabled
|
||||
? "text-emerald-500"
|
||||
: "text-primary group-hover:text-emerald-500"
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>SSO Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Require visitors to authenticate against your identity provider
|
||||
before reaching this application.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<AlertBlock type="warning">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Requirements</span>
|
||||
<ol className="list-decimal pl-4 text-sm">
|
||||
<li>
|
||||
The authentication proxy container must be deployed and running
|
||||
on this app's server. Configure it under{" "}
|
||||
<span className="font-medium">
|
||||
Settings → SSO → Application Authentication
|
||||
</span>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
This domain must share the same base domain as the
|
||||
authentication domain (e.g. <code>app.acme.com</code> and{" "}
|
||||
<code>auth.acme.com</code>).
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
Protect this domain with SSO
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isEnabled
|
||||
? "Visitors must log in via your identity provider."
|
||||
: "The domain is publicly accessible."}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
disabled={isPending}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -62,7 +62,6 @@ import { api } from "@/utils/api";
|
||||
import { createColumns } from "./columns";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
||||
|
||||
export type ValidationState = {
|
||||
isLoading: boolean;
|
||||
@@ -454,12 +453,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canCreateDomain && type === "application" && (
|
||||
<HandleForwardAuth
|
||||
domainId={item.domainId}
|
||||
applicationId={id}
|
||||
/>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +5,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -7,6 +6,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +5,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +5,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +5,7 @@ import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
+2
-2
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +5,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -422,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -7,6 +6,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
+1
-1
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +5,7 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
+1
-1
@@ -449,7 +449,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,4 +1,3 @@
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -6,6 +5,7 @@ import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -440,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
|
||||
@@ -71,9 +71,6 @@ interface Props {
|
||||
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
@@ -174,8 +171,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Select a Server{" "}
|
||||
{showLocalOption ? "(Optional)" : ""}
|
||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
</TooltipTrigger>
|
||||
@@ -195,19 +191,17 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
showLocalOption ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{showLocalOption && (
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
@@ -231,8 +225,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers (
|
||||
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
|
||||
@@ -74,9 +74,6 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.compose.create.useMutation();
|
||||
@@ -185,8 +182,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Select a Server{" "}
|
||||
{showLocalOption ? "(Optional)" : ""}
|
||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormLabel>
|
||||
</TooltipTrigger>
|
||||
@@ -206,19 +202,17 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
showLocalOption ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{showLocalOption && (
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
@@ -242,8 +236,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers (
|
||||
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
|
||||
@@ -219,9 +219,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const slug = slugify(projectName);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const libsqlMutation = api.libsql.create.useMutation();
|
||||
const mariadbMutation = api.mariadb.create.useMutation();
|
||||
@@ -473,20 +470,19 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={
|
||||
field.value ||
|
||||
(showLocalOption ? "dokploy" : undefined)
|
||||
field.value || (!isCloud ? "dokploy" : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
showLocalOption ? "Dokploy" : "Select a Server"
|
||||
!isCloud ? "Dokploy" : "Select a Server"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{showLocalOption && (
|
||||
{!isCloud && (
|
||||
<SelectItem value="dokploy">
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>Dokploy</span>
|
||||
@@ -505,8 +501,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers (
|
||||
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreditCard, FileText } from "lucide-react";
|
||||
import { CreditCard, FileText, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
@@ -17,6 +17,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -82,6 +83,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
icon: <Clock className="size-3" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
provisioning: {
|
||||
label: "Provisioning",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
configuring: {
|
||||
label: "Installing Dokploy",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
variant: "default",
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
icon: <XCircle className="size-3" />,
|
||||
variant: "destructive",
|
||||
},
|
||||
terminating: {
|
||||
label: "Terminating",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
terminated: {
|
||||
label: "Terminated",
|
||||
icon: <AlertCircle className="size-3" />,
|
||||
variant: "outline",
|
||||
},
|
||||
};
|
||||
|
||||
function formatSpecs(cpus: number, memoryMb: number, diskMb: number, bandwidthMb: number) {
|
||||
const bandwidthTb = bandwidthMb / 1024 / 1024;
|
||||
const bandwidthLabel = bandwidthTb >= 1 ? `${bandwidthTb.toFixed(0)} TB` : `${Math.round(bandwidthMb / 1024)} GB`;
|
||||
return `${cpus} vCPU · ${Math.round(memoryMb / 1024)} GB RAM · ${Math.round(diskMb / 1024)} GB NVMe · ${bandwidthLabel} bandwidth`;
|
||||
}
|
||||
|
||||
function centsToDisplay(cents: number) {
|
||||
return (cents / 100).toFixed(2).replace(/\.00$/, "");
|
||||
}
|
||||
|
||||
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>("");
|
||||
const [selectedDc, setSelectedDc] = useState<string>("");
|
||||
const [isAnnual, setIsAnnual] = useState(false);
|
||||
|
||||
const { data: plans, isLoading: loadingPlans } =
|
||||
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
|
||||
const { data: dataCenters, isLoading: loadingDcs } =
|
||||
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
|
||||
|
||||
const isLoadingOptions = loadingPlans || loadingDcs;
|
||||
|
||||
const purchase = api.managedServer.purchase.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server order placed! Provisioning will take ~5 minutes.");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const plan = plans?.find((p) => p.id === selectedPlan);
|
||||
|
||||
const displayPrice = (p: NonNullable<typeof plan>) =>
|
||||
isAnnual
|
||||
? `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr`
|
||||
: `$${centsToDisplay(p.dokployPriceCentsMonthly)}/mo`;
|
||||
|
||||
const displayPriceSmall = (p: NonNullable<typeof plan>) =>
|
||||
isAnnual
|
||||
? `$${centsToDisplay(Math.round(p.dokployPriceCentsAnnual / 12))}/mo billed annually`
|
||||
: `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr if annual`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="size-4 mr-2" />
|
||||
Order Server
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Order a Managed Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll provision and configure a server for you automatically. Ready
|
||||
in ~5 minutes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading available plans...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Billing period toggle */}
|
||||
<div className="flex items-center gap-1 rounded-lg border p-1 bg-muted/40 w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAnnual(false)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||
!isAnnual
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAnnual(true)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5",
|
||||
isAnnual
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Annual
|
||||
<span className="text-xs bg-green-500/15 text-green-600 dark:text-green-400 px-1.5 py-0.5 rounded font-semibold">
|
||||
Save ~20%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plan selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<div className="grid gap-2">
|
||||
{plans?.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlan(p.id)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-lg border p-3 text-left transition-colors",
|
||||
selectedPlan === p.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{p.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSpecs(p.cpus, p.memoryMb, p.diskMb, p.bandwidthMb)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-sm">
|
||||
{displayPrice(p)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{displayPriceSmall(p)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data center selector */}
|
||||
<div className="space-y-2">
|
||||
<Label>Data Center</Label>
|
||||
<Select value={selectedDc} onValueChange={setSelectedDc}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a location..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4} className="max-h-56 overflow-y-auto">
|
||||
{dataCenters?.map((dc) => (
|
||||
<SelectItem key={dc.id} value={String(dc.id)}>
|
||||
{dc.city} — {dc.continent}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{plan && selectedDc && (
|
||||
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Plan</span>
|
||||
<span className="font-medium">{plan.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Billing</span>
|
||||
<span className="font-medium">{isAnnual ? "Annual" : "Monthly"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-semibold">{displayPrice(plan)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedPlan || !selectedDc || purchase.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedPlan || !selectedDc) return;
|
||||
purchase.mutate({
|
||||
plan: selectedPlan,
|
||||
dataCenterId: Number(selectedDc),
|
||||
isAnnual,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{purchase.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Placing order...
|
||||
</>
|
||||
) : (
|
||||
"Order Server"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ShowManagedServers = () => {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: servers, isLoading } = api.managedServer.list.useQuery();
|
||||
|
||||
const syncStatus = api.managedServer.syncStatus.useMutation({
|
||||
onSuccess: () => utils.managedServer.list.invalidate(),
|
||||
});
|
||||
|
||||
const deleteServer = api.managedServer.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server terminated.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="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">
|
||||
<Server className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and servers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">Managed Servers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Servers provisioned and managed by Dokploy Cloud
|
||||
</p>
|
||||
</div>
|
||||
<OrderServerDialog
|
||||
onSuccess={() => utils.managedServer.list.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : servers?.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg border-dashed">
|
||||
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No managed servers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Order a server and we'll provision and configure it for you
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers?.map((s) => {
|
||||
const status =
|
||||
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
|
||||
const isProvisioning = [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
].includes(s.status);
|
||||
const planLabel = s.plan
|
||||
.split("-")
|
||||
.slice(-2)
|
||||
.join(" ")
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.managedServerId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="size-5 text-muted-foreground shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{planLabel}
|
||||
</span>
|
||||
<Badge
|
||||
variant={status?.variant}
|
||||
className="flex items-center gap-1 text-xs h-5"
|
||||
>
|
||||
{status?.icon}
|
||||
{status?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.hostname ?? ""}
|
||||
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isProvisioning && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
syncStatus.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={syncStatus.isPending}
|
||||
>
|
||||
<Loader2
|
||||
className={cn(
|
||||
"size-4",
|
||||
syncStatus.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && s.server && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/dashboard/settings/server?serverId=${s.serverId}`}
|
||||
>
|
||||
<ExternalLink className="size-3.5 mr-1.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DialogAction
|
||||
title="Terminate Server"
|
||||
description="This will permanently destroy the server and all data on it. This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={() =>
|
||||
deleteServer.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -49,11 +49,7 @@ export const ShowGitProviders = () => {
|
||||
api.gitProvider.remove.useMutation();
|
||||
const { mutateAsync: toggleShare, isPending: isToggling } =
|
||||
api.gitProvider.toggleShare.useMutation();
|
||||
const { data: currentMember } = api.user.get.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const url = useUrl();
|
||||
const isOrgAdmin =
|
||||
currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
const getGitlabUrl = (
|
||||
clientId: string,
|
||||
@@ -91,20 +87,18 @@ export const ShowGitProviders = () => {
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GitBranch className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
No Git Providers configured
|
||||
Create your first Git Provider
|
||||
</span>
|
||||
{permissions?.gitProviders.create && (
|
||||
<div>
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
@@ -112,16 +106,14 @@ export const ShowGitProviders = () => {
|
||||
<span className="text-base font-medium">
|
||||
Available Providers
|
||||
</span>
|
||||
{permissions?.gitProviders.create && (
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
@@ -131,13 +123,17 @@ export const ShowGitProviders = () => {
|
||||
const isBitbucket =
|
||||
gitProvider.providerType === "bitbucket";
|
||||
const isGitea = gitProvider.providerType === "gitea";
|
||||
const canManage = gitProvider.isOwner || isOrgAdmin;
|
||||
|
||||
const haveGithubRequirements =
|
||||
isGithub && gitProvider.github?.isConfigured;
|
||||
isGithub &&
|
||||
gitProvider.github?.githubPrivateKey &&
|
||||
gitProvider.github?.githubAppId &&
|
||||
gitProvider.github?.githubInstallationId;
|
||||
|
||||
const haveGitlabRequirements =
|
||||
isGitlab && gitProvider.gitlab?.isConfigured;
|
||||
isGitlab &&
|
||||
gitProvider.gitlab?.accessToken &&
|
||||
gitProvider.gitlab?.refreshToken;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -225,7 +221,8 @@ export const ShowGitProviders = () => {
|
||||
)}
|
||||
|
||||
{isBitbucket &&
|
||||
gitProvider.bitbucket?.isDeprecated ? (
|
||||
gitProvider.bitbucket?.appPassword &&
|
||||
!gitProvider.bitbucket?.apiToken ? (
|
||||
<Badge variant="yellow">Deprecated</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -238,7 +235,7 @@ export const ShowGitProviders = () => {
|
||||
Action Required
|
||||
</Badge>
|
||||
<Link
|
||||
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github?.githubId}`}
|
||||
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github.githubId}`}
|
||||
className={buttonVariants({
|
||||
size: "icon",
|
||||
variant: "ghost",
|
||||
@@ -274,7 +271,7 @@ export const ShowGitProviders = () => {
|
||||
href={getGitlabUrl(
|
||||
gitProvider.gitlab?.applicationId || "",
|
||||
gitProvider.gitlab?.gitlabId || "",
|
||||
gitProvider.gitlab?.gitlabUrl || "",
|
||||
gitProvider.gitlab?.gitlabUrl,
|
||||
)}
|
||||
target="_blank"
|
||||
className={buttonVariants({
|
||||
@@ -287,35 +284,31 @@ export const ShowGitProviders = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
{gitProvider.isOwner && (
|
||||
<>
|
||||
{isGithub &&
|
||||
haveGithubRequirements &&
|
||||
gitProvider.github?.githubId && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github.githubId}
|
||||
/>
|
||||
)}
|
||||
{isGithub && haveGithubRequirements && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github?.githubId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitlab &&
|
||||
gitProvider.gitlab?.gitlabId && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab.gitlabId}
|
||||
/>
|
||||
)}
|
||||
{isGitlab && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBitbucket &&
|
||||
gitProvider.bitbucket?.bitbucketId && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isBitbucket && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket?.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitea && gitProvider.gitea?.giteaId && (
|
||||
{isGitea && (
|
||||
<EditGiteaProvider
|
||||
giteaId={gitProvider.gitea.giteaId}
|
||||
giteaId={gitProvider.gitea?.giteaId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ToggleEnforceSSO = () => {
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
const { mutateAsync } = api.settings.updateEnforceSSO.useMutation();
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
try {
|
||||
await mutateAsync({ enforceSSO: checked });
|
||||
await refetch();
|
||||
toast.success("Enforce SSO updated");
|
||||
} catch {
|
||||
toast.error("Error updating Enforce SSO");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch checked={!!data?.enforceSSO} onCheckedChange={handleToggle} />
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||
Enforce SSO
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
When enabled, the email/password login form is hidden and users
|
||||
must sign in exclusively through SSO.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ToggleRemoteServersOnly = () => {
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
|
||||
const { mutateAsync } = api.settings.updateRemoteServersOnly.useMutation();
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
try {
|
||||
await mutateAsync({ remoteServersOnly: checked });
|
||||
await refetch();
|
||||
toast.success("Remote Servers Only updated");
|
||||
} catch {
|
||||
toast.error("Error updating Remote Servers Only");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={!!data?.remoteServersOnly}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||
Remote Servers Only
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
When enabled, all services (applications, databases, compose) must
|
||||
be deployed to a remote server. Deploying directly to the Dokploy
|
||||
host VM is not allowed.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@@ -54,7 +53,6 @@ const Schema = z.object({
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
||||
enableDockerCleanup: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
@@ -92,7 +90,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -106,7 +103,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
username: data?.username || "root",
|
||||
sshKeyId: data?.sshKeyId || "",
|
||||
serverType: data?.serverType || "deploy",
|
||||
enableDockerCleanup: data?.enableDockerCleanup ?? true,
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
@@ -123,7 +119,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: data.serverType || "deploy",
|
||||
enableDockerCleanup: data.enableDockerCleanup,
|
||||
serverId: serverId || "",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
@@ -423,27 +418,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableDockerCleanup"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enable Docker Cleanup</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically prune unused Docker images daily. Keeps disk
|
||||
usage in check on this remote server.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -131,10 +131,10 @@ export const ShowServers = () => {
|
||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-lg break-words min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">
|
||||
{server.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ export const ShowServers = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
More options
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, ServerIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,6 +7,8 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { toast } from "sonner";
|
||||
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
||||
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
||||
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Copy,
|
||||
Dices,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DnsHelperModal } from "@/components/dashboard/application/domains/dns-helper-modal";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServerStatus = "running" | "stopped" | "unknown";
|
||||
type Target = { serverId: string | null; name: string };
|
||||
type CertType = "none" | "letsencrypt" | "custom";
|
||||
type DomainForm = {
|
||||
host: string;
|
||||
https: boolean;
|
||||
certificateType: CertType;
|
||||
customCertResolver: string;
|
||||
};
|
||||
|
||||
export const ForwardAuthServers = () => {
|
||||
const utils = api.useUtils();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [deployTarget, setDeployTarget] = useState<Target | null>(null);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||
const [forms, setForms] = useState<Record<string, DomainForm>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setEnabled(true), 0);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const { data: hostIp } = api.settings.getIp.useQuery();
|
||||
const { data: servers, isPending } = api.forwardAuth.serverStatus.useQuery(
|
||||
undefined,
|
||||
{ enabled, refetchOnWindowFocus: false, staleTime: 30_000 },
|
||||
);
|
||||
const { data: providers } = api.forwardAuth.listProviders.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !!deployTarget,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: saveAuthDomain, isPending: isSaving } =
|
||||
api.forwardAuth.setAuthDomain.useMutation();
|
||||
const { mutateAsync: deployOnServer, isPending: isDeploying } =
|
||||
api.forwardAuth.deployOnServer.useMutation();
|
||||
const { mutateAsync: removeOnServer, isPending: isRemoving } =
|
||||
api.forwardAuth.removeOnServer.useMutation();
|
||||
const { mutateAsync: generateDomain, isPending: isGenerating } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const keyOf = (serverId: string | null) => serverId ?? "local";
|
||||
|
||||
useEffect(() => {
|
||||
if (!servers) return;
|
||||
setForms((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const srv of servers) {
|
||||
const key = srv.serverId ?? "local";
|
||||
if (next[key] === undefined) {
|
||||
next[key] = {
|
||||
host: srv.authDomain ?? "",
|
||||
https: srv.https ?? true,
|
||||
certificateType: (srv.certificateType ?? "letsencrypt") as CertType,
|
||||
customCertResolver: srv.customCertResolver ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [servers]);
|
||||
|
||||
const hasProviders = (providers?.length ?? 0) > 0;
|
||||
|
||||
const patchForm = (serverId: string | null, patch: Partial<DomainForm>) =>
|
||||
setForms((p) => {
|
||||
const key = keyOf(serverId);
|
||||
const current: DomainForm = p[key] ?? {
|
||||
host: "",
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
customCertResolver: "",
|
||||
};
|
||||
return { ...p, [key]: { ...current, ...patch } };
|
||||
});
|
||||
|
||||
const handleSaveDomain = async (serverId: string | null) => {
|
||||
const f = forms[keyOf(serverId)];
|
||||
if (!f?.host.trim()) {
|
||||
toast.error("Enter an auth domain first");
|
||||
return false;
|
||||
}
|
||||
if (f.certificateType === "custom" && !f.customCertResolver.trim()) {
|
||||
toast.error("Enter the custom certificate resolver");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await saveAuthDomain({
|
||||
serverId,
|
||||
authDomain: f.host.trim(),
|
||||
https: f.https,
|
||||
certificateType: f.certificateType,
|
||||
customCertResolver: f.customCertResolver.trim() || undefined,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error saving auth domain",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!deployTarget || !selectedProviderId) {
|
||||
toast.error("Select an SSO provider first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = await handleSaveDomain(deployTarget.serverId);
|
||||
if (!saved) return;
|
||||
await deployOnServer({
|
||||
serverId: deployTarget.serverId,
|
||||
providerId: selectedProviderId,
|
||||
});
|
||||
await utils.forwardAuth.serverStatus.invalidate();
|
||||
toast.success("Authentication proxy deployed");
|
||||
setDeployTarget(null);
|
||||
setSelectedProviderId("");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error deploying proxy",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (serverId: string | null) => {
|
||||
try {
|
||||
await removeOnServer({ serverId });
|
||||
await utils.forwardAuth.serverStatus.invalidate();
|
||||
toast.success("Authentication proxy removed");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error removing proxy",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateDomain = async (serverId: string | null) => {
|
||||
try {
|
||||
const host = await generateDomain({
|
||||
appName: "auth",
|
||||
serverId: serverId ?? undefined,
|
||||
});
|
||||
patchForm(serverId, { host, https: false, certificateType: "none" });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error generating domain",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (status: ServerStatus) => {
|
||||
if (status === "running") {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-500/40 text-emerald-500"
|
||||
>
|
||||
<ShieldCheck className="mr-1 size-3" />
|
||||
Running
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === "stopped") {
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<ShieldOff className="mr-1 size-3" />
|
||||
Not deployed
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-500"
|
||||
title="Could not reach this server in time"
|
||||
>
|
||||
<HelpCircle className="mr-1 size-3" />
|
||||
Unknown
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<ShieldCheck className="size-5" />
|
||||
Application Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Each server has its own authentication domain and proxy. Set an auth
|
||||
domain (e.g. auth.acme.com) per server, register its callback URL once
|
||||
in your identity provider, then deploy the proxy. Apps on that server
|
||||
under the same base domain are then one click to protect.
|
||||
<span className="mt-2 block font-medium">
|
||||
Only OIDC providers are supported — SAML is not compatible with the
|
||||
forward-auth proxy.
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isPending || !enabled ? (
|
||||
<div className="flex items-center gap-2 justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<span className="text-sm">Checking servers...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{servers?.map((srv) => {
|
||||
const key = keyOf(srv.serverId);
|
||||
const f = forms[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{srv.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge(srv.status)}
|
||||
{srv.status === "running" && (
|
||||
<DialogAction
|
||||
title="Remove authentication proxy"
|
||||
description="Domains on this server protected with SSO will stop requiring authentication until re-enabled. Continue?"
|
||||
type="destructive"
|
||||
onClick={() => handleRemove(srv.serverId)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">Auth domain</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="auth.acme.com"
|
||||
value={f?.host ?? ""}
|
||||
onChange={(e) =>
|
||||
patchForm(srv.serverId, { host: e.target.value })
|
||||
}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{f?.host && !f.host.includes("sslip.io") && (
|
||||
<DnsHelperModal
|
||||
domain={{
|
||||
host: f.host,
|
||||
https: f.https,
|
||||
}}
|
||||
serverIp={
|
||||
srv.ipAddress ?? hostIp?.toString() ?? undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
isLoading={isGenerating}
|
||||
title="Generate sslip.io domain"
|
||||
onClick={() => handleGenerateDomain(srv.serverId)}
|
||||
>
|
||||
<Dices className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
Certificate provider
|
||||
</span>
|
||||
<Select
|
||||
value={f?.https ? f.certificateType : "none"}
|
||||
onValueChange={(v) =>
|
||||
patchForm(srv.serverId, {
|
||||
certificateType: v as CertType,
|
||||
https: v !== "none",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None (HTTP)</SelectItem>
|
||||
<SelectItem value="letsencrypt">
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{f?.certificateType === "custom" && f?.https && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
Custom certificate resolver
|
||||
</span>
|
||||
<Input
|
||||
placeholder="Enter your custom certificate resolver"
|
||||
value={f?.customCertResolver ?? ""}
|
||||
onChange={(e) =>
|
||||
patchForm(srv.serverId, {
|
||||
customCertResolver: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!f?.host?.trim()}
|
||||
onClick={() =>
|
||||
setDeployTarget({
|
||||
serverId: srv.serverId,
|
||||
name: srv.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{srv.callbackUrl && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium">
|
||||
Callback URL (register once in your IdP)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={srv.callbackUrl}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
srv.callbackUrl as string,
|
||||
);
|
||||
toast.success("Callback URL copied");
|
||||
}}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog
|
||||
open={!!deployTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeployTarget(null);
|
||||
setSelectedProviderId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deploy authentication proxy</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deploy the SSO proxy on{" "}
|
||||
<span className="font-medium">{deployTarget?.name}</span> using an
|
||||
OIDC provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!hasProviders && (
|
||||
<AlertBlock type="warning">
|
||||
No SSO providers configured. Add an OIDC provider above first.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<span className="text-sm font-medium">Identity provider</span>
|
||||
<Select
|
||||
value={selectedProviderId}
|
||||
onValueChange={setSelectedProviderId}
|
||||
disabled={!hasProviders}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an SSO provider">
|
||||
{selectedProviderId || ""}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.map((provider) => (
|
||||
<SelectItem
|
||||
key={provider.providerId}
|
||||
value={provider.providerId}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{provider.providerId}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{provider.issuer}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isSaving || isDeploying}
|
||||
disabled={!hasProviders || !selectedProviderId}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -29,15 +29,10 @@ type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
|
||||
|
||||
interface SignInWithSSOProps {
|
||||
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
||||
children?: React.ReactNode;
|
||||
/** When true, SSO is the only option — no fallback to email/password */
|
||||
enforce?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SignInWithSSO({
|
||||
children,
|
||||
enforce = false,
|
||||
}: SignInWithSSOProps) {
|
||||
export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const form = useForm<SSOEmailForm>({
|
||||
@@ -77,7 +72,7 @@ export function SignInWithSSO({
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
{!enforce && children}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,15 +113,13 @@ export function SignInWithSSO({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!enforce && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Use email and password instead
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Use email and password instead
|
||||
</button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -167,13 +167,7 @@ export const CodeEditor = ({
|
||||
? css()
|
||||
: language === "shell"
|
||||
? StreamLanguage.define(shell)
|
||||
: StreamLanguage.define({
|
||||
...properties,
|
||||
// The legacy properties mode lacks comment metadata, so
|
||||
// CodeMirror's toggle-comment shortcut (Mod-/) has no comment
|
||||
// token to use. Declare `#` as the line comment for env editors.
|
||||
languageData: { commentTokens: { line: "#" } },
|
||||
}),
|
||||
: StreamLanguage.define(properties),
|
||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||
language === "yaml"
|
||||
? autocompletion({
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
|
||||
CREATE TABLE "managed_server" (
|
||||
"managedServerId" text PRIMARY KEY NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"plan" text NOT NULL,
|
||||
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
|
||||
"hostingerVmId" integer,
|
||||
"hostingerSubscriptionId" text,
|
||||
"dataCenterId" integer NOT NULL,
|
||||
"ipAddress" text,
|
||||
"hostname" text,
|
||||
"stripeSubscriptionId" text,
|
||||
"stripePriceId" text,
|
||||
"rootPassword" text,
|
||||
"errorMessage" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"updatedAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;
|
||||
@@ -1,11 +0,0 @@
|
||||
ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD COLUMN "organizationId" text;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
UPDATE "schedule" s
|
||||
SET "organizationId" = m."organization_id"
|
||||
FROM "member" m
|
||||
WHERE s."scheduleType" = 'dokploy-server'
|
||||
AND s."userId" = m."user_id"
|
||||
AND m."role" = 'owner';--> statement-breakpoint
|
||||
ALTER TABLE "schedule" DROP COLUMN "userId";
|
||||
@@ -1,16 +0,0 @@
|
||||
CREATE TABLE "forward_auth_settings" (
|
||||
"forwardAuthSettingsId" text PRIMARY KEY NOT NULL,
|
||||
"authDomain" text NOT NULL,
|
||||
"baseDomain" text NOT NULL,
|
||||
"https" boolean DEFAULT true NOT NULL,
|
||||
"certificateType" "certificateType" DEFAULT 'letsencrypt' NOT NULL,
|
||||
"customCertResolver" text,
|
||||
"providerId" text,
|
||||
"serverId" text,
|
||||
"createdAt" text NOT NULL,
|
||||
CONSTRAINT "forward_auth_settings_serverId_unique" UNIQUE("serverId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "domain" ADD COLUMN "forwardAuthEnabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_providerId_sso_provider_provider_id_fk" FOREIGN KEY ("providerId") REFERENCES "public"."sso_provider"("provider_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "forward_auth_settings" ADD CONSTRAINT "forward_auth_settings_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE "sso_provider" DROP CONSTRAINT "sso_provider_user_id_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "de1ea564-75f5-431f-adb8-7c4db357dde5",
|
||||
"id": "20e31523-69b6-4261-ac1d-c1dde8d6d8b7",
|
||||
"prevId": "887c0c81-4af9-477a-ab29-b3ad16f08451",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -3886,6 +3886,144 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.managed_server": {
|
||||
"name": "managed_server",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"managedServerId": {
|
||||
"name": "managedServerId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"organizationId": {
|
||||
"name": "organizationId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"serverId": {
|
||||
"name": "serverId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"plan": {
|
||||
"name": "plan",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "managedServerStatus",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"hostingerVmId": {
|
||||
"name": "hostingerVmId",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"hostingerSubscriptionId": {
|
||||
"name": "hostingerSubscriptionId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"dataCenterId": {
|
||||
"name": "dataCenterId",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ipAddress": {
|
||||
"name": "ipAddress",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"hostname": {
|
||||
"name": "hostname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"stripeSubscriptionId": {
|
||||
"name": "stripeSubscriptionId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"stripePriceId": {
|
||||
"name": "stripePriceId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rootPassword": {
|
||||
"name": "rootPassword",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"errorMessage": {
|
||||
"name": "errorMessage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"managed_server_organizationId_organization_id_fk": {
|
||||
"name": "managed_server_organizationId_organization_id_fk",
|
||||
"tableFrom": "managed_server",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organizationId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"managed_server_serverId_server_serverId_fk": {
|
||||
"name": "managed_server_serverId_server_serverId_fk",
|
||||
"tableFrom": "managed_server",
|
||||
"tableTo": "server",
|
||||
"columnsFrom": [
|
||||
"serverId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"serverId"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mariadb": {
|
||||
"name": "mariadb",
|
||||
"schema": "",
|
||||
@@ -8021,13 +8159,6 @@
|
||||
"notNull": false,
|
||||
"default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"metaTitle\":null,\"footerText\":null}'::jsonb"
|
||||
},
|
||||
"remoteServersOnly": {
|
||||
"name": "remoteServersOnly",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"cleanupCacheApplications": {
|
||||
"name": "cleanupCacheApplications",
|
||||
"type": "boolean",
|
||||
@@ -8168,6 +8299,19 @@
|
||||
"gitea"
|
||||
]
|
||||
},
|
||||
"public.managedServerStatus": {
|
||||
"name": "managedServerStatus",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
"ready",
|
||||
"error",
|
||||
"terminating",
|
||||
"terminated"
|
||||
]
|
||||
},
|
||||
"public.mountType": {
|
||||
"name": "mountType",
|
||||
"schema": "public",
|
||||
@@ -8322,4 +8466,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1174,36 +1174,8 @@
|
||||
{
|
||||
"idx": 167,
|
||||
"version": "7",
|
||||
"when": 1780122576214,
|
||||
"tag": "0167_fresh_goliath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 168,
|
||||
"version": "7",
|
||||
"when": 1780122833339,
|
||||
"tag": "0168_long_justice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 169,
|
||||
"version": "7",
|
||||
"when": 1780127552074,
|
||||
"tag": "0169_parched_johnny_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 170,
|
||||
"version": "7",
|
||||
"when": 1780739532982,
|
||||
"tag": "0170_amusing_spot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 171,
|
||||
"version": "7",
|
||||
"when": 1780775037209,
|
||||
"tag": "0171_lucky_echo",
|
||||
"when": 1778657133470,
|
||||
"tag": "0167_dizzy_solo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.8",
|
||||
"version": "v0.29.4",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -123,7 +123,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.2.6",
|
||||
"next": "^16.2.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { HelpCircle, ServerOff } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
@@ -10,8 +10,8 @@ import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { toast } from "sonner";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { HelpCircle, ServerOff } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
@@ -10,8 +10,8 @@ import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { toast } from "sonner";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { HelpCircle, ServerOff } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
@@ -10,8 +10,8 @@ import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { toast } from "sonner";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
|
||||
+1
-1
@@ -10,8 +10,8 @@ import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { toast } from "sonner";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { HelpCircle, ServerOff } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
@@ -10,8 +10,8 @@ import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { toast } from "sonner";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { HelpCircle, ServerOff } from "lucide-react";
|
||||
import type {
|
||||
GetServerSidePropsContext,
|
||||
@@ -10,8 +10,8 @@ import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { toast } from "sonner";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
|
||||
@@ -5,13 +5,18 @@ import type { ReactElement } from "react";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
function SchedulesPage() {
|
||||
const { data: user } = api.user.get.useQuery();
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
|
||||
<div className="rounded-xl bg-background shadow-md h-full">
|
||||
<ShowSchedules scheduleType="dokploy-server" id="dokploy-server" />
|
||||
<ShowSchedules
|
||||
scheduleType="dokploy-server"
|
||||
id={user?.user.id || ""}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
|
||||
const Page = () => {
|
||||
return <ShowManagedServers />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext,
|
||||
) {
|
||||
if (!IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { props: {} };
|
||||
}
|
||||
@@ -1,28 +1,15 @@
|
||||
import { IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||
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 { ToggleEnforceSSO } from "@/components/dashboard/settings/servers/actions/toggle-enforce-sso";
|
||||
import { ToggleRemoteServersOnly } from "@/components/dashboard/settings/servers/actions/toggle-remote-servers-only";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { ForwardAuthServers } from "@/components/proprietary/sso/forward-auth-servers";
|
||||
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
interface Props {
|
||||
isCloud: boolean;
|
||||
}
|
||||
|
||||
const Page = ({ isCloud }: Props) => {
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
@@ -42,47 +29,6 @@ const Page = ({ isCloud }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Application Authentication",
|
||||
description:
|
||||
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<ForwardAuthServers />
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</Card>
|
||||
{!isCloud && (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Self-hosted Restrictions",
|
||||
description:
|
||||
"Deployment and authentication restrictions are part of Dokploy Enterprise. Add a valid license to configure them.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">
|
||||
Self-hosted Restrictions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control deployment targets and authentication behavior.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<ToggleRemoteServersOnly />
|
||||
<ToggleEnforceSSO />
|
||||
</CardContent>
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -130,7 +76,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
isCloud: IS_CLOUD,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
isAdminPresent,
|
||||
} from "@dokploy/server";
|
||||
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
@@ -56,9 +52,8 @@ type LoginForm = z.infer<typeof LoginSchema>;
|
||||
|
||||
interface Props {
|
||||
IS_CLOUD: boolean;
|
||||
enforceSSO: boolean;
|
||||
}
|
||||
export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
export default function Home({ IS_CLOUD }: Props) {
|
||||
const router = useRouter();
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
@@ -252,9 +247,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
<CardContent className="p-0">
|
||||
{!isTwoFactor ? (
|
||||
<>
|
||||
{enforceSSO ? (
|
||||
<SignInWithSSO enforce />
|
||||
) : showSignInWithSSO ? (
|
||||
{showSignInWithSSO ? (
|
||||
<SignInWithSSO>{loginContent}</SignInWithSSO>
|
||||
) : (
|
||||
loginContent
|
||||
@@ -424,7 +417,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
props: {
|
||||
IS_CLOUD: IS_CLOUD,
|
||||
enforceSSO: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -450,12 +442,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
|
||||
return {
|
||||
props: {
|
||||
hasAdmin,
|
||||
enforceSSO: webServerSettings?.enforceSSO ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||
import { projectRouter } from "./routers/project";
|
||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
import { redirectsRouter } from "./routers/redirects";
|
||||
@@ -94,7 +94,6 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
forwardAuth: forwardAuthRouter,
|
||||
whitelabeling: whitelabelingRouter,
|
||||
customRole: customRoleRouter,
|
||||
auditLog: auditLogRouter,
|
||||
@@ -104,6 +103,7 @@ export const appRouter = createTRPCRouter({
|
||||
environment: environmentRouter,
|
||||
tag: tagRouter,
|
||||
patch: patchRouter,
|
||||
managedServer: managedServerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
deleteAllMiddlewares,
|
||||
findApplicationById,
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getApplicationStats,
|
||||
getContainerLogs,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
mechanizeDockerContainer,
|
||||
readConfig,
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
writeConfigRemote,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
@@ -88,11 +87,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create an application",
|
||||
@@ -174,11 +169,13 @@ export const applicationRouter = createTRPCRouter({
|
||||
const gitProviderId = getGitProviderId();
|
||||
|
||||
if (gitProviderId) {
|
||||
const canEdit = await canEditDeployGitSource(
|
||||
gitProviderId,
|
||||
ctx.session,
|
||||
);
|
||||
if (!canEdit) {
|
||||
try {
|
||||
const gitProvider = await findGitProviderById(gitProviderId);
|
||||
if (gitProvider.userId !== ctx.session.userId) {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = application.sourceType;
|
||||
}
|
||||
} catch {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = application.sourceType;
|
||||
}
|
||||
|
||||
@@ -96,11 +96,9 @@ export const clusterRouter = createTRPCRouter({
|
||||
const docker = await getRemoteDocker(input.serverId);
|
||||
const result = await docker.swarmInspect();
|
||||
const docker_version = await docker.version();
|
||||
const info = await docker.info();
|
||||
|
||||
const swarmNodeAddr = info?.Swarm?.NodeAddr;
|
||||
let ip = swarmNodeAddr || (await getLocalServerIp());
|
||||
if (!swarmNodeAddr && input.serverId) {
|
||||
let ip = await getLocalServerIp();
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
ip = server?.ipAddress;
|
||||
}
|
||||
@@ -130,11 +128,9 @@ export const clusterRouter = createTRPCRouter({
|
||||
const docker = await getRemoteDocker(input.serverId);
|
||||
const result = await docker.swarmInspect();
|
||||
const docker_version = await docker.version();
|
||||
const info = await docker.info();
|
||||
|
||||
const swarmNodeAddr = info?.Swarm?.NodeAddr;
|
||||
let ip = swarmNodeAddr || (await getLocalServerIp());
|
||||
if (!swarmNodeAddr && input.serverId) {
|
||||
let ip = await getLocalServerIp();
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
ip = server?.ipAddress;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
findComposeById,
|
||||
findDomainsByComposeId,
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
findServerById,
|
||||
getAccessibleServerIds,
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
@@ -91,11 +91,7 @@ export const composeRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a compose",
|
||||
@@ -173,11 +169,13 @@ export const composeRouter = createTRPCRouter({
|
||||
const gitProviderId = getGitProviderId();
|
||||
|
||||
if (gitProviderId) {
|
||||
const canEdit = await canEditDeployGitSource(
|
||||
gitProviderId,
|
||||
ctx.session,
|
||||
);
|
||||
if (!canEdit) {
|
||||
try {
|
||||
const gitProvider = await findGitProviderById(gitProviderId);
|
||||
if (gitProvider.userId !== ctx.session.userId) {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = compose.sourceType;
|
||||
}
|
||||
} catch {
|
||||
hasGitProviderAccess = false;
|
||||
unauthorizedProvider = compose.sourceType;
|
||||
}
|
||||
@@ -587,11 +585,7 @@ export const composeRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, environment.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a compose",
|
||||
|
||||
@@ -38,7 +38,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
return await getContainers(input.serverId);
|
||||
}),
|
||||
|
||||
restartContainer: withPermission("docker", "read")
|
||||
restartContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
@@ -64,7 +64,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
startContainer: withPermission("docker", "read")
|
||||
startContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
@@ -90,7 +90,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
stopContainer: withPermission("docker", "read")
|
||||
stopContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
@@ -116,7 +116,7 @@ export const dockerRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
|
||||
killContainer: withPermission("docker", "read")
|
||||
killContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
|
||||
@@ -42,43 +42,6 @@ export const gitProviderRouter = createTRPCRouter({
|
||||
return results.map((r) => ({
|
||||
...r,
|
||||
isOwner: r.userId === ctx.session.userId,
|
||||
github: r.github
|
||||
? {
|
||||
githubId: r.github.githubId,
|
||||
githubAppName: r.github.githubAppName,
|
||||
githubAppId: r.github.githubAppId,
|
||||
githubInstallationId: r.github.githubInstallationId,
|
||||
isConfigured: !!(
|
||||
r.github.githubPrivateKey &&
|
||||
r.github.githubAppId &&
|
||||
r.github.githubInstallationId
|
||||
),
|
||||
}
|
||||
: null,
|
||||
gitlab: r.gitlab
|
||||
? {
|
||||
gitlabId: r.gitlab.gitlabId,
|
||||
applicationId: r.gitlab.applicationId,
|
||||
gitlabUrl: r.gitlab.gitlabUrl,
|
||||
isConfigured: !!(r.gitlab.accessToken && r.gitlab.refreshToken),
|
||||
}
|
||||
: null,
|
||||
bitbucket: r.bitbucket
|
||||
? {
|
||||
bitbucketId: r.bitbucket.bitbucketId,
|
||||
bitbucketUsername: r.bitbucket.bitbucketUsername,
|
||||
isConfigured: false,
|
||||
isDeprecated: !!(r.bitbucket.appPassword && !r.bitbucket.apiToken),
|
||||
}
|
||||
: null,
|
||||
gitea: r.gitea
|
||||
? {
|
||||
giteaId: r.gitea.giteaId,
|
||||
giteaUrl: r.gitea.giteaUrl,
|
||||
clientId: r.gitea.clientId,
|
||||
isConfigured: !!(r.gitea.accessToken && r.gitea.refreshToken),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
}),
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeLibsqlById,
|
||||
@@ -52,11 +51,7 @@ export const libsqlRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a Libsql",
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeMariadbById,
|
||||
@@ -63,11 +62,7 @@ export const mariadbRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a Mariadb",
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeMongoById,
|
||||
@@ -62,11 +61,7 @@ export const mongoRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a mongo",
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeMySqlById,
|
||||
@@ -63,11 +62,7 @@ export const mysqlRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a MySQL",
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getContainerLogs,
|
||||
getMountPath,
|
||||
getServiceContainerCommand,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removePostgresById,
|
||||
@@ -64,11 +63,7 @@ export const postgresRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a Postgres",
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import {
|
||||
assertApplicationDomainAccess,
|
||||
deployForwardAuthOnServer,
|
||||
disableForwardAuthOnDomain,
|
||||
enableForwardAuthOnDomain,
|
||||
findServerById,
|
||||
forwardAuthCallbackUrl,
|
||||
getDomainSsoStatus,
|
||||
getForwardAuthServerStatus,
|
||||
getForwardAuthSettings,
|
||||
listSsoProvidersForOrg,
|
||||
removeForwardAuthProxy,
|
||||
removeForwardAuthSettings,
|
||||
setForwardAuthSettings,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
apiDeployForwardAuthOnServer,
|
||||
apiForwardAuthDomainTarget,
|
||||
apiForwardAuthServerTarget,
|
||||
apiSetForwardAuthSettings,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
enterpriseProcedure,
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
|
||||
export const forwardAuthRouter = createTRPCRouter({
|
||||
getAuthDomain: enterpriseProcedure
|
||||
.input(apiForwardAuthServerTarget)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
const settings = await getForwardAuthSettings(input.serverId);
|
||||
if (!settings) return null;
|
||||
return {
|
||||
host: settings.authDomain,
|
||||
https: settings.https,
|
||||
certificateType: settings.certificateType,
|
||||
customCertResolver: settings.customCertResolver,
|
||||
callbackUrl: forwardAuthCallbackUrl(
|
||||
settings.authDomain,
|
||||
settings.https,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
setAuthDomain: enterpriseProcedure
|
||||
.input(apiSetForwardAuthSettings)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await setForwardAuthSettings({
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
serverId: input.serverId,
|
||||
authDomain: input.authDomain,
|
||||
https: input.https,
|
||||
certificateType: input.certificateType,
|
||||
customCertResolver: input.customCertResolver,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: "forward-auth-domain",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
removeAuthDomain: enterpriseProcedure
|
||||
.input(apiForwardAuthServerTarget)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await removeForwardAuthSettings(input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: "forward-auth-domain",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
listProviders: enterpriseProcedure.query(({ ctx }) =>
|
||||
listSsoProvidersForOrg(ctx.session.activeOrganizationId),
|
||||
),
|
||||
|
||||
serverStatus: enterpriseProcedure.query(({ ctx }) =>
|
||||
getForwardAuthServerStatus(ctx.session.activeOrganizationId),
|
||||
),
|
||||
|
||||
deployOnServer: enterpriseProcedure
|
||||
.input(apiDeployForwardAuthOnServer)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await deployForwardAuthOnServer({
|
||||
serverId: input.serverId ?? undefined,
|
||||
providerId: input.providerId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: "forward-auth",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
removeOnServer: enterpriseProcedure
|
||||
.input(apiForwardAuthServerTarget)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await removeForwardAuthProxy(input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId ?? "local",
|
||||
resourceName: "forward-auth",
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
status: withPermission("domain", "read")
|
||||
.input(apiForwardAuthDomainTarget)
|
||||
.query(({ ctx, input }) => getDomainSsoStatus(ctx, input.domainId)),
|
||||
|
||||
enable: withPermission("domain", "create")
|
||||
.input(apiForwardAuthDomainTarget)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const domain = await assertApplicationDomainAccess(
|
||||
ctx,
|
||||
input.domainId,
|
||||
"create",
|
||||
);
|
||||
const result = await enableForwardAuthOnDomain({
|
||||
domainId: input.domainId,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "domain",
|
||||
resourceId: domain.domainId,
|
||||
resourceName: domain.host,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
disable: withPermission("domain", "create")
|
||||
.input(apiForwardAuthDomainTarget)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const domain = await assertApplicationDomainAccess(
|
||||
ctx,
|
||||
input.domainId,
|
||||
"create",
|
||||
);
|
||||
const result = await disableForwardAuthOnDomain({
|
||||
domainId: input.domainId,
|
||||
});
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "domain",
|
||||
resourceId: domain.domainId,
|
||||
resourceName: domain.host,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
createServer,
|
||||
IS_CLOUD,
|
||||
serverSetup,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
apiCreateManagedServer,
|
||||
apiDeleteManagedServer,
|
||||
apiFindOneManagedServer,
|
||||
} from "@dokploy/server/db/schema/managed-server";
|
||||
import {
|
||||
createManagedServer,
|
||||
deleteManagedServer,
|
||||
findManagedServerById,
|
||||
findManagedServersByOrg,
|
||||
updateManagedServer,
|
||||
} from "@dokploy/server/services/managed-server";
|
||||
import {
|
||||
getHostingerDataCenters,
|
||||
getHostingerVm,
|
||||
getManagedServerPlans,
|
||||
purchaseHostingerVps,
|
||||
stopHostingerVm,
|
||||
UBUNTU_22_TEMPLATE_ID,
|
||||
} from "@dokploy/server/utils/hostinger";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { adminProcedure, createTRPCRouter } from "../../trpc";
|
||||
|
||||
export const managedServerRouter = createTRPCRouter({
|
||||
getPlans: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return getManagedServerPlans();
|
||||
}),
|
||||
|
||||
getDataCenters: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return getHostingerDataCenters();
|
||||
}),
|
||||
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return [];
|
||||
return findManagedServersByOrg(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
one: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return record;
|
||||
}),
|
||||
|
||||
purchase: adminProcedure
|
||||
.input(apiCreateManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
const plans = await getManagedServerPlans();
|
||||
const plan = plans.find((p) => p.id === input.plan);
|
||||
if (!plan) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
|
||||
}
|
||||
|
||||
const hostname =
|
||||
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
|
||||
|
||||
const managedRecord = await createManagedServer({
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
plan: input.plan,
|
||||
dataCenterId: input.dataCenterId,
|
||||
status: "provisioning",
|
||||
});
|
||||
|
||||
const hostingerItemId = input.isAnnual
|
||||
? plan.hostingerItemIdAnnual
|
||||
: plan.hostingerItemIdMonthly;
|
||||
|
||||
provisionManagedServer(
|
||||
managedRecord.managedServerId,
|
||||
hostingerItemId,
|
||||
input.dataCenterId,
|
||||
hostname,
|
||||
ctx.session.activeOrganizationId,
|
||||
).catch(async (err) => {
|
||||
await updateManagedServer(managedRecord.managedServerId, {
|
||||
status: "error",
|
||||
errorMessage: err?.message ?? "Unknown error during provisioning",
|
||||
});
|
||||
});
|
||||
|
||||
return managedRecord;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(apiDeleteManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
status: "terminating",
|
||||
});
|
||||
|
||||
if (record.hostingerVmId) {
|
||||
try {
|
||||
await stopHostingerVm(record.hostingerVmId);
|
||||
} catch (_) {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
|
||||
await deleteManagedServer(input.managedServerId);
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
syncStatus: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if (!record.hostingerVmId) return record;
|
||||
|
||||
const vm = await getHostingerVm(record.hostingerVmId);
|
||||
const ipAddress = vm.ipv4?.[0]?.address ?? record.ipAddress;
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
hostname: vm.hostname ?? undefined,
|
||||
status:
|
||||
vm.state === "running"
|
||||
? record.serverId
|
||||
? "ready"
|
||||
: "configuring"
|
||||
: record.status,
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
});
|
||||
|
||||
async function provisionManagedServer(
|
||||
managedServerId: string,
|
||||
hostingerItemId: string,
|
||||
dataCenterId: number,
|
||||
hostname: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const result = await purchaseHostingerVps({
|
||||
item_id: hostingerItemId,
|
||||
payment_method_id: 0,
|
||||
setup: {
|
||||
template_id: UBUNTU_22_TEMPLATE_ID,
|
||||
data_center_id: dataCenterId,
|
||||
hostname,
|
||||
enable_backups: false,
|
||||
},
|
||||
coupons: [],
|
||||
});
|
||||
|
||||
const vm = result.virtual_machine;
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
hostingerVmId: vm.id,
|
||||
hostingerSubscriptionId: vm.subscription_id ?? undefined,
|
||||
ipAddress: vm.ipv4?.[0]?.address ?? undefined,
|
||||
hostname: vm.hostname ?? undefined,
|
||||
status: "configuring",
|
||||
});
|
||||
|
||||
await waitForVmRunning(vm.id!, managedServerId);
|
||||
|
||||
const finalVm = await getHostingerVm(vm.id!);
|
||||
const finalIp = finalVm.ipv4?.[0]?.address;
|
||||
|
||||
if (!finalIp) {
|
||||
throw new Error("VM is running but has no IPv4 address");
|
||||
}
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${hostname}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: finalIp,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
},
|
||||
organizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
ipAddress: finalIp,
|
||||
});
|
||||
|
||||
await serverSetup(serverRecord.serverId);
|
||||
|
||||
await updateManagedServer(managedServerId, { status: "ready" });
|
||||
}
|
||||
|
||||
async function waitForVmRunning(
|
||||
vmId: number,
|
||||
_managedServerId: string,
|
||||
maxAttempts = 30,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getHostingerVm(vmId);
|
||||
if (vm.state === "running") return;
|
||||
if (vm.state === "error") {
|
||||
throw new Error("VM entered error state");
|
||||
}
|
||||
}
|
||||
throw new Error("Timed out waiting for VM to become running");
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
requestToHeaders,
|
||||
} from "@dokploy/server/index";
|
||||
import { auth } from "@dokploy/server/lib/auth";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
@@ -44,16 +43,12 @@ export const ssoRouter = createTRPCRouter({
|
||||
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
|
||||
);
|
||||
}),
|
||||
enforceSSO: publicProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return false;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
return settings?.enforceSSO ?? false;
|
||||
}),
|
||||
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
where: and(
|
||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(ssoProvider.userId, ctx.session.userId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
providerId: true,
|
||||
@@ -85,6 +80,7 @@ export const ssoRouter = createTRPCRouter({
|
||||
where: and(
|
||||
eq(ssoProvider.providerId, input.providerId),
|
||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(ssoProvider.userId, ctx.session.userId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -112,12 +108,12 @@ export const ssoRouter = createTRPCRouter({
|
||||
where: and(
|
||||
eq(ssoProvider.providerId, input.providerId),
|
||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(ssoProvider.userId, ctx.session.userId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
issuer: true,
|
||||
domain: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -129,13 +125,6 @@ export const ssoRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (existing.userId !== ctx.session.userId) {
|
||||
await db
|
||||
.update(ssoProvider)
|
||||
.set({ userId: ctx.session.userId })
|
||||
.where(eq(ssoProvider.id, existing.id));
|
||||
}
|
||||
|
||||
const providers = await db.query.ssoProvider.findMany({
|
||||
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
columns: { providerId: true, domain: true },
|
||||
@@ -221,6 +210,7 @@ export const ssoRouter = createTRPCRouter({
|
||||
where: and(
|
||||
eq(ssoProvider.providerId, input.providerId),
|
||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(ssoProvider.userId, ctx.session.userId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -243,6 +233,7 @@ export const ssoRouter = createTRPCRouter({
|
||||
and(
|
||||
eq(ssoProvider.providerId, input.providerId),
|
||||
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
eq(ssoProvider.userId, ctx.session.userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: ssoProvider.id });
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
rebuildDatabase,
|
||||
removeRedisById,
|
||||
@@ -60,11 +59,7 @@ export const redisRouter = createTRPCRouter({
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
const webServerSettings = await getWebServerSettings();
|
||||
if (
|
||||
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
|
||||
!input.serverId
|
||||
) {
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You need to use a server to create a Redis",
|
||||
|
||||
@@ -75,12 +75,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
}
|
||||
const newSchedule = await createSchedule({
|
||||
...input,
|
||||
...(input.scheduleType === "dokploy-server" && {
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
}),
|
||||
});
|
||||
const newSchedule = await createSchedule(input);
|
||||
|
||||
if (newSchedule?.enabled) {
|
||||
if (IS_CLOUD) {
|
||||
@@ -167,6 +162,17 @@ export const scheduleRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
existingSchedule.scheduleType === "dokploy-server" &&
|
||||
existingSchedule.userId &&
|
||||
existingSchedule.userId !== ctx.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You can only manage your own host-level schedules.",
|
||||
});
|
||||
}
|
||||
}
|
||||
const updatedSchedule = await updateSchedule(input);
|
||||
|
||||
@@ -250,6 +256,17 @@ export const scheduleRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
scheduleItem.scheduleType === "dokploy-server" &&
|
||||
scheduleItem.userId &&
|
||||
scheduleItem.userId !== ctx.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You can only manage your own host-level schedules.",
|
||||
});
|
||||
}
|
||||
}
|
||||
await deleteSchedule(input.scheduleId);
|
||||
|
||||
@@ -306,27 +323,21 @@ export const scheduleRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
if (input.scheduleType === "dokploy-server") {
|
||||
const member = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (member.role !== "owner" && member.role !== "admin") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only owners and admins can list host-level schedules.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
input.scheduleType === "dokploy-server" &&
|
||||
input.id !== ctx.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You can only list your own host-level schedules.",
|
||||
});
|
||||
}
|
||||
}
|
||||
const where = {
|
||||
application: eq(schedules.applicationId, input.id),
|
||||
compose: eq(schedules.composeId, input.id),
|
||||
server: eq(schedules.serverId, input.id),
|
||||
"dokploy-server": eq(
|
||||
schedules.organizationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
),
|
||||
"dokploy-server": eq(schedules.userId, input.id),
|
||||
};
|
||||
return db.query.schedules.findMany({
|
||||
where: where[input.scheduleType],
|
||||
@@ -365,6 +376,17 @@ export const scheduleRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
schedule.scheduleType === "dokploy-server" &&
|
||||
schedule.userId &&
|
||||
schedule.userId !== ctx.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You don't have access to this schedule.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return schedule;
|
||||
}),
|
||||
@@ -417,6 +439,17 @@ export const scheduleRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
scheduleItem.scheduleType === "dokploy-server" &&
|
||||
scheduleItem.userId &&
|
||||
scheduleItem.userId !== ctx.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You can only manage your own host-level schedules.",
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
await runCommand(input.scheduleId);
|
||||
|
||||
@@ -45,7 +45,7 @@ export const securityRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const security = await findSecurityById(input.securityId);
|
||||
await checkServicePermissionAndAccess(ctx, security.applicationId, {
|
||||
service: ["create"],
|
||||
service: ["delete"],
|
||||
});
|
||||
const result = await deleteSecurityById(input.securityId);
|
||||
await audit(ctx, {
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
redis,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
||||
|
||||
export const serverRouter = createTRPCRouter({
|
||||
create: withPermission("server", "create")
|
||||
@@ -64,11 +63,6 @@ export const serverRouter = createTRPCRouter({
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await applyDockerCleanupSchedule(
|
||||
project.serverId,
|
||||
ctx.session.activeOrganizationId,
|
||||
input.enableDockerCleanup,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "server",
|
||||
@@ -462,12 +456,6 @@ export const serverRouter = createTRPCRouter({
|
||||
...input,
|
||||
});
|
||||
|
||||
await applyDockerCleanupSchedule(
|
||||
input.serverId,
|
||||
ctx.session.activeOrganizationId,
|
||||
input.enableDockerCleanup,
|
||||
);
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
|
||||
@@ -77,7 +77,6 @@ import { appRouter } from "../root";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
enterpriseProcedure,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
@@ -446,50 +445,6 @@ export const settingsRouter = createTRPCRouter({
|
||||
return true;
|
||||
}),
|
||||
|
||||
updateRemoteServersOnly: enterpriseProcedure
|
||||
.input(z.object({ remoteServersOnly: z.boolean() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available for self-hosted instances",
|
||||
});
|
||||
}
|
||||
|
||||
await updateWebServerSettings({
|
||||
remoteServersOnly: input.remoteServersOnly,
|
||||
});
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "settings",
|
||||
resourceName: "remote-servers-only",
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
updateEnforceSSO: enterpriseProcedure
|
||||
.input(z.object({ enforceSSO: z.boolean() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available for self-hosted instances",
|
||||
});
|
||||
}
|
||||
|
||||
await updateWebServerSettings({
|
||||
enforceSSO: input.enforceSSO,
|
||||
});
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "settings",
|
||||
resourceName: "enforce-sso",
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
readTraefikConfig: adminProcedure.query(() => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import {
|
||||
CLEANUP_CRON_JOB,
|
||||
cleanupAll,
|
||||
IS_CLOUD,
|
||||
sendDockerCleanupNotifications,
|
||||
} from "@dokploy/server";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { removeJob, schedule } from "./backup";
|
||||
|
||||
export const applyDockerCleanupSchedule = async (
|
||||
serverId: string,
|
||||
organizationId: string,
|
||||
enable: boolean,
|
||||
) => {
|
||||
if (enable) {
|
||||
if (IS_CLOUD) {
|
||||
await schedule({
|
||||
cronSchedule: CLEANUP_CRON_JOB,
|
||||
serverId,
|
||||
type: "server",
|
||||
});
|
||||
} else {
|
||||
scheduleJob(serverId, CLEANUP_CRON_JOB, async () => {
|
||||
await cleanupAll(serverId);
|
||||
await sendDockerCleanupNotifications(organizationId);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (IS_CLOUD) {
|
||||
await removeJob({
|
||||
cronSchedule: CLEANUP_CRON_JOB,
|
||||
serverId,
|
||||
type: "server",
|
||||
});
|
||||
} else {
|
||||
scheduledJobs[serverId]?.cancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -61,6 +61,7 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"hostinger-api-sdk": "^0.0.17",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
|
||||
@@ -55,7 +55,6 @@ export const domains = pgTable("domain", {
|
||||
internalPath: text("internalPath").default("/"),
|
||||
stripPath: boolean("stripPath").notNull().default(false),
|
||||
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
|
||||
forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false),
|
||||
});
|
||||
|
||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||
@@ -95,7 +94,6 @@ export const apiCreateDomain = createSchema.pick({
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
middlewares: true,
|
||||
forwardAuthEnabled: true,
|
||||
});
|
||||
|
||||
export const apiFindDomain = z.object({
|
||||
@@ -128,6 +126,5 @@ export const apiUpdateDomain = createSchema
|
||||
internalPath: true,
|
||||
stripPath: true,
|
||||
middlewares: true,
|
||||
forwardAuthEnabled: true,
|
||||
})
|
||||
.merge(createSchema.pick({ domainId: true }).required());
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { server } from "./server";
|
||||
import { certificateType } from "./shared";
|
||||
import { ssoProvider } from "./sso";
|
||||
|
||||
export const forwardAuthSettings = pgTable("forward_auth_settings", {
|
||||
forwardAuthSettingsId: text("forwardAuthSettingsId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
authDomain: text("authDomain").notNull(),
|
||||
baseDomain: text("baseDomain").notNull(),
|
||||
https: boolean("https").notNull().default(true),
|
||||
certificateType: certificateType("certificateType")
|
||||
.notNull()
|
||||
.default("letsencrypt"),
|
||||
customCertResolver: text("customCertResolver"),
|
||||
providerId: text("providerId").references(() => ssoProvider.providerId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
serverId: text("serverId")
|
||||
.unique()
|
||||
.references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const forwardAuthSettingsRelations = relations(
|
||||
forwardAuthSettings,
|
||||
({ one }) => ({
|
||||
server: one(server, {
|
||||
fields: [forwardAuthSettings.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
provider: one(ssoProvider, {
|
||||
fields: [forwardAuthSettings.providerId],
|
||||
references: [ssoProvider.providerId],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
|
||||
|
||||
export const apiForwardAuthServerTarget = z.object({
|
||||
serverId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const apiForwardAuthDomainTarget = z.object({
|
||||
domainId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiSetForwardAuthSettings = z.object({
|
||||
serverId: z.string().nullable(),
|
||||
authDomain: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.refine((v) => domainRegex.test(v), { message: "Invalid auth domain" }),
|
||||
https: z.boolean().default(true),
|
||||
certificateType: z
|
||||
.enum(["none", "letsencrypt", "custom"])
|
||||
.default("letsencrypt"),
|
||||
customCertResolver: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiDeployForwardAuthOnServer = z.object({
|
||||
serverId: z.string().nullable(),
|
||||
providerId: z.string().min(1),
|
||||
});
|
||||
@@ -10,12 +10,12 @@ export * from "./deployment";
|
||||
export * from "./destination";
|
||||
export * from "./domain";
|
||||
export * from "./environment";
|
||||
export * from "./forward-auth";
|
||||
export * from "./git-provider";
|
||||
export * from "./gitea";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./libsql";
|
||||
export * from "./managed-server";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
|
||||
export const managedServerStatus = pgEnum("managedServerStatus", [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
"ready",
|
||||
"error",
|
||||
"terminating",
|
||||
"terminated",
|
||||
]);
|
||||
|
||||
export const managedServer = pgTable("managed_server", {
|
||||
managedServerId: text("managedServerId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** Hostinger catalog item id, e.g. "hostingercom-vps-kvm2" */
|
||||
plan: text("plan").notNull(),
|
||||
status: managedServerStatus("status").notNull().default("pending"),
|
||||
hostingerVmId: integer("hostingerVmId"),
|
||||
hostingerSubscriptionId: text("hostingerSubscriptionId"),
|
||||
dataCenterId: integer("dataCenterId").notNull(),
|
||||
ipAddress: text("ipAddress"),
|
||||
hostname: text("hostname"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
stripePriceId: text("stripePriceId"),
|
||||
rootPassword: text("rootPassword"),
|
||||
errorMessage: text("errorMessage"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text("updatedAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const managedServerRelations = relations(managedServer, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [managedServer.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [managedServer.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apiCreateManagedServer = z.object({
|
||||
plan: z.string().min(1),
|
||||
dataCenterId: z.number().int().positive(),
|
||||
isAnnual: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const apiFindOneManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiDeleteManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
@@ -3,11 +3,11 @@ import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { applications } from "./application";
|
||||
import { compose } from "./compose";
|
||||
import { deployments } from "./deployment";
|
||||
import { server } from "./server";
|
||||
import { user } from "./user";
|
||||
import { generateAppName } from "./utils";
|
||||
export const shellTypes = pgEnum("shellType", ["bash", "sh"]);
|
||||
|
||||
@@ -46,7 +46,7 @@ export const schedules = pgTable("schedule", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
organizationId: text("organizationId").references(() => organization.id, {
|
||||
userId: text("userId").references(() => user.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
@@ -71,9 +71,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
||||
fields: [schedules.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
organization: one(organization, {
|
||||
fields: [schedules.organizationId],
|
||||
references: [organization.id],
|
||||
user: one(user, {
|
||||
fields: [schedules.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
deployments: many(deployments),
|
||||
}));
|
||||
|
||||
@@ -147,12 +147,8 @@ export const apiCreateServer = createSchema
|
||||
username: true,
|
||||
sshKeyId: true,
|
||||
serverType: true,
|
||||
enableDockerCleanup: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
enableDockerCleanup: z.boolean().default(true),
|
||||
});
|
||||
.required();
|
||||
|
||||
export const apiFindOneServer = z.object({
|
||||
serverId: z.string().min(1),
|
||||
@@ -174,12 +170,10 @@ export const apiUpdateServer = createSchema
|
||||
username: true,
|
||||
sshKeyId: true,
|
||||
serverType: true,
|
||||
enableDockerCleanup: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
command: z.string().optional(),
|
||||
enableDockerCleanup: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const apiUpdateServerMonitoring = createSchema
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ssoProvider = pgTable("sso_provider", {
|
||||
oidcConfig: text("oidc_config"),
|
||||
samlConfig: text("saml_config"),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
organizationId: text("organization_id").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
@@ -96,10 +96,6 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
}),
|
||||
// Deployment Configuration (self-hosted only)
|
||||
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
|
||||
// Auth Configuration (self-hosted only)
|
||||
enforceSSO: boolean("enforceSSO").notNull().default(false),
|
||||
// Cache Cleanup Configuration
|
||||
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
||||
.notNull()
|
||||
@@ -159,8 +155,6 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
||||
cleanupCacheApplications: z.boolean().optional(),
|
||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||
cleanupCacheOnCompose: z.boolean().optional(),
|
||||
remoteServersOnly: z.boolean().optional(),
|
||||
enforceSSO: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiAssignDomain = z
|
||||
|
||||
@@ -35,7 +35,6 @@ export * from "./services/port";
|
||||
export * from "./services/postgres";
|
||||
export * from "./services/preview-deployment";
|
||||
export * from "./services/project";
|
||||
export * from "./services/proprietary/forward-auth";
|
||||
export * from "./services/proprietary/license-key";
|
||||
export * from "./services/proprietary/sso";
|
||||
export * from "./services/redirect";
|
||||
@@ -44,6 +43,7 @@ export * from "./services/registry";
|
||||
export * from "./services/rollbacks";
|
||||
export * from "./services/schedule";
|
||||
export * from "./services/security";
|
||||
export * from "./services/managed-server";
|
||||
export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
@@ -51,7 +51,6 @@ export * from "./services/user";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
export * from "./setup/forward-auth-setup";
|
||||
export * from "./setup/monitoring-setup";
|
||||
export * from "./setup/postgres-setup";
|
||||
export * from "./setup/redis-setup";
|
||||
@@ -102,7 +101,6 @@ export * from "./utils/docker/types";
|
||||
export * from "./utils/docker/utils";
|
||||
export * from "./utils/filesystem/directory";
|
||||
export * from "./utils/filesystem/ssh";
|
||||
export * from "./utils/git-branch-validation";
|
||||
export * from "./utils/gpu-setup";
|
||||
export * from "./utils/notifications/build-error";
|
||||
export * from "./utils/notifications/build-success";
|
||||
@@ -111,6 +109,7 @@ export * from "./utils/notifications/docker-cleanup";
|
||||
export * from "./utils/notifications/dokploy-restart";
|
||||
export * from "./utils/notifications/server-threshold";
|
||||
export * from "./utils/notifications/utils";
|
||||
export * from "./utils/git-branch-validation";
|
||||
export * from "./utils/process/execAsync";
|
||||
export * from "./utils/process/spawnAsync";
|
||||
export * from "./utils/providers/bitbucket";
|
||||
@@ -129,7 +128,6 @@ export * from "./utils/tracking/hubspot";
|
||||
export * from "./utils/traefik/application";
|
||||
export * from "./utils/traefik/domain";
|
||||
export * from "./utils/traefik/file-types";
|
||||
export * from "./utils/traefik/forward-auth";
|
||||
export * from "./utils/traefik/middleware";
|
||||
export * from "./utils/traefik/redirect";
|
||||
export * from "./utils/traefik/security";
|
||||
|
||||
@@ -95,22 +95,26 @@ export const findApplicationById = async (applicationId: string) => {
|
||||
const application = await db.query.applications.findFirst({
|
||||
where: eq(applications.applicationId, applicationId),
|
||||
with: {
|
||||
environment: { with: { project: true } },
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
domains: true,
|
||||
deployments: true,
|
||||
mounts: true,
|
||||
redirects: true,
|
||||
security: true,
|
||||
ports: true,
|
||||
registry: true,
|
||||
gitlab: true,
|
||||
github: true,
|
||||
bitbucket: true,
|
||||
gitea: true,
|
||||
server: true,
|
||||
previewDeployments: true,
|
||||
registry: { columns: { password: false } },
|
||||
buildRegistry: { columns: { password: false } },
|
||||
rollbackRegistry: { columns: { password: false } },
|
||||
buildRegistry: true,
|
||||
rollbackRegistry: true,
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
|
||||
@@ -34,12 +34,7 @@ export const findBackupById = async (backupId: string) => {
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
destination: true,
|
||||
compose: true,
|
||||
},
|
||||
});
|
||||
@@ -88,12 +83,7 @@ export const findBackupsByDbId = async (
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
destination: true,
|
||||
},
|
||||
});
|
||||
return result || [];
|
||||
|
||||
@@ -131,12 +131,7 @@ export const findComposeById = async (composeId: string) => {
|
||||
server: true,
|
||||
backups: {
|
||||
with: {
|
||||
destination: {
|
||||
columns: {
|
||||
accessKey: false,
|
||||
secretAccessKey: false,
|
||||
},
|
||||
},
|
||||
destination: true,
|
||||
deployments: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -43,38 +43,6 @@ export const updateGitProvider = async (
|
||||
.then((response) => response[0]);
|
||||
};
|
||||
|
||||
// Returns true if the user can edit the git source configuration of an existing
|
||||
// deploy that is connected to the given provider.
|
||||
// Owner/admin: always yes.
|
||||
// Member: only if they own the provider or it's shared with the org.
|
||||
// Being in accessedGitProviders only grants permission to connect NEW deploys,
|
||||
// not to modify the git config of an existing deploy owned by someone else.
|
||||
export const canEditDeployGitSource = async (
|
||||
gitProviderId: string,
|
||||
session: { userId: string; activeOrganizationId: string },
|
||||
): Promise<boolean> => {
|
||||
const { userId, activeOrganizationId } = session;
|
||||
|
||||
const memberRecord = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, userId),
|
||||
eq(member.organizationId, activeOrganizationId),
|
||||
),
|
||||
columns: { role: true },
|
||||
});
|
||||
|
||||
if (memberRecord?.role === "owner") return true;
|
||||
|
||||
const provider = await db.query.gitProvider.findFirst({
|
||||
where: eq(gitProvider.gitProviderId, gitProviderId),
|
||||
columns: { userId: true, sharedWithOrganization: true },
|
||||
});
|
||||
|
||||
if (!provider) return false;
|
||||
|
||||
return provider.userId === userId || provider.sharedWithOrganization;
|
||||
};
|
||||
|
||||
export const getAccessibleGitProviderIds = async (session: {
|
||||
userId: string;
|
||||
activeOrganizationId: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user