Compare commits

..

2 Commits

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

View File

@@ -138,8 +138,6 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -162,80 +160,3 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"

21
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -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

View File

@@ -110,24 +110,3 @@ jobs:
echo "✅ OpenAPI synced to CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to SDK repository successfully"

80
.github/workflows/sync-version.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Sync version to MCP and CLI repos
on:
release:
types: [published]
workflow_dispatch:
jobs:
sync-version:
name: Sync version to external repos
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
- name: Get version
id: get_version
run: |
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
# Regenerate tools from latest OpenAPI spec
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
# Copy latest openapi spec and regenerate commands
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
if [ -f package.json ]; then
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
fi
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"

View File

@@ -4,8 +4,5 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}

View File

@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]

View File

@@ -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"');
});
});

View File

@@ -34,7 +34,6 @@ describe("Host rule format regression tests", () => {
stripPath: false,
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
};
describe("Host rule format validation", () => {

View File

@@ -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");

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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");

View File

@@ -494,49 +494,4 @@ describe("processTemplate", () => {
expect(result.mounts).toHaveLength(1);
});
});
describe("isolated deployment config", () => {
it("should default to isolated=true when not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
},
};
expect(template.config.isolated).toBeUndefined();
// undefined !== false => isolatedDeployment = true
expect(template.config.isolated !== false).toBe(true);
});
it("should be isolated when isolated=true is explicitly set", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: true,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(true);
});
it("should disable isolated deployment when isolated=false", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: false,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(false);
});
});
});

View File

@@ -30,7 +30,9 @@ describe("helpers functions", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
).toBeTruthy();
});
});

View File

@@ -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);
});
});

View File

@@ -65,8 +65,6 @@ const baseSettings: WebServerSettings = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
remoteServersOnly: false,
enforceSSO: false,
createdAt: null,
updatedAt: new Date(),
};

View File

@@ -148,7 +148,6 @@ const baseDomain: Domain = {
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
const baseRedirect: Redirect = {

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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">

View File

@@ -21,9 +21,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import type { ValidationStates } from "./show-domains";
import { AddDomain } from "./handle-domain";
import { DnsHelperModal } from "./dns-helper-modal";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
@@ -168,7 +168,7 @@ export const createColumns = ({
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("sslip.io") && (
{!domain.host.includes("traefik.me") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -256,7 +256,7 @@ export const createColumns = ({
return (
<div className="flex items-center gap-2">
{!domain.host.includes("sslip.io") && (
{!domain.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: domain.host,

View File

@@ -225,7 +225,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false;
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -513,7 +513,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("sslip.io") && (
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
@@ -524,12 +524,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your sslip.io domain work.
to make your traefik.me domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -567,7 +567,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate sslip.io domain</p>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;
@@ -426,7 +425,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
)}
<div className="flex gap-2 flex-wrap">
{!item.host.includes("sslip.io") && (
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: item.host,
@@ -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"

View File

@@ -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";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(),

View File

@@ -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";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -111,103 +107,110 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-3">
<div className="flex items-center justify-between h-5">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
/>
{sshKeys && sshKeys.length > 0 ? (
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="sshKey"
name="branch"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-1">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
className="col-span-2 lg:col-span-1 lg:mt-7"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem className="col-span-2">
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
@@ -220,7 +223,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-4">
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>

View File

@@ -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";
@@ -73,10 +72,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]),
enableSubmodules: z.boolean().optional(),

View File

@@ -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";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),

View File

@@ -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";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
id: z.number().nullable(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
{canDeploy && (
<DialogAction
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Terminal className="size-4 mr-1" />
Open Terminal
</Button>
</DockerTerminalModal>
{canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
)}
{canUpdateService && (
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"

View File

@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("sslip.io") || false;
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
<FormItem>
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate sslip.io domain</p>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
env: "",
wildcardDomain: "*.sslip.io",
wildcardDomain: "*.traefik.me",
port: 3000,
previewLimit: 3,
previewLabels: [],
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.sslip.io",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3,
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> sslip.io is a public HTTP service and
<strong>Note:</strong> traefik.me is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect.
</AlertBlock>
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormControl>
<Input placeholder="*.sslip.io" {...field} />
<Input placeholder="*.traefik.me" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -80,7 +80,6 @@ export const commonCronExpressions = [
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
@@ -225,7 +224,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
description: "",
cronExpression: "",
shellType: "bash",
command: "",
@@ -265,7 +263,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
if (scheduleId && schedule) {
form.reset({
name: schedule.name,
description: schedule.description || "",
cronExpression: schedule.cronExpression,
shellType: schedule.shellType,
command: schedule.command,
@@ -482,26 +479,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Backs up the database every day at midnight"
{...field}
/>
</FormControl>
<FormDescription>
Optional description of what this schedule does
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ScheduleFormField
name="cronExpression"
formControl={form.control}

View File

@@ -125,11 +125,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
{schedule.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{schedule.description && (
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
{schedule.description}
</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge
variant="outline"

View File

@@ -2,10 +2,6 @@ import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -40,6 +36,10 @@ import {
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
const DockerLogsId = dynamic(
() =>

View File

@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
const composeFile = form.watch("composeFile");
useEffect(() => {
if (data) {
if (data && !composeFile) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, data]);
}, [form, form.reset, data]);
useEffect(() => {
if (data?.composeFile !== undefined) {

View File

@@ -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";
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -422,7 +418,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>

View File

@@ -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";
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -1,6 +1,5 @@
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 { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -58,10 +57,7 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),

View File

@@ -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";
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
@@ -449,7 +445,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>

View File

@@ -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";
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
gitlabPathNamespace: z.string().min(1),
})
.required(),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -440,7 +436,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>

View File

@@ -288,6 +288,7 @@ export const RestoreBackup = ({
toast.error("Please select a database type");
return;
}
console.log({ data });
setIsDeploying(true);
};

View File

@@ -1,14 +1,5 @@
"use client";
import copy from "copy-to-clipboard";
import {
Bot,
Check,
Copy,
Loader2,
RotateCcw,
Settings,
X,
} from "lucide-react";
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
@@ -39,7 +30,6 @@ const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const [copied, setCopied] = useState(false);
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
@@ -62,15 +52,6 @@ export function AnalyzeLogs({ logs, context }: Props) {
mutate({ aiId, logs: logsText, context });
};
const handleCopy = () => {
if (!data?.analysis) return;
const success = copy(data.analysis);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Popover
open={open}
@@ -90,7 +71,7 @@ export function AnalyzeLogs({ logs, context }: Props) {
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 size-4" />
<Bot className="mr-2 h-4 w-4" />
AI
</Button>
</PopoverTrigger>
@@ -187,18 +168,6 @@ export function AnalyzeLogs({ logs, context }: Props) {
)}
Re-analyze
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
title="Copy analysis to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="sm"
variant="ghost"

View File

@@ -347,13 +347,11 @@ export const DockerLogsId: React.FC<Props> = ({
title={isPaused ? "Resume logs" : "Pause logs"}
>
{isPaused ? (
<Play className="size-4" />
<Play className="mr-2 h-4 w-4" />
) : (
<Pause className="size-4" />
<Pause className="mr-2 h-4 w-4" />
)}
<span className="hidden lg:ml-2 lg:inline">
{isPaused ? "Resume" : "Pause"}
</span>
{isPaused ? "Resume" : "Pause"}
</Button>
<Button
variant="outline"
@@ -364,13 +362,11 @@ export const DockerLogsId: React.FC<Props> = ({
title="Copy logs to clipboard"
>
{copied ? (
<Check className="size-4" />
<Check className="mr-2 h-4 w-4" />
) : (
<Copy className="size-4" />
<Copy className="mr-2 h-4 w-4" />
)}
<span className="hidden lg:ml-2 lg:inline">
{copied ? "Copied" : "Copy"}
</span>
Copy
</Button>
<Button
variant="outline"
@@ -378,18 +374,17 @@ export const DockerLogsId: React.FC<Props> = ({
className="h-9 sm:w-auto w-full"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
>
<DownloadIcon className="size-4" />
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (
<AlertBlock type="warning" className="items-center">
<AlertBlock type="warning">
<div className="flex items-center gap-2">
<Pause className="size-4" />
<Pause className="h-4 w-4" />
<span>
Logs paused
{messageBuffer.length > 0 && (

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -16,6 +15,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -16,6 +15,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {

View File

@@ -26,8 +26,8 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
type UploadFileToContainer,
uploadFileToContainerSchema,
type UploadFileToContainer,
} from "@/utils/schema";
interface Props {

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mariadbId: string;

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mongoId: string;

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mysqlId: string;

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
postgresId: string;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
@@ -637,6 +632,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
control={form.control}
name="enableNamespaces"
render={({ field }) => {
console.log(field.value);
return (
<FormItem>
<FormLabel>Enable Namespaces</FormLabel>

View File

@@ -1,494 +0,0 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddImportSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
appName: z
.string()
.min(1, { message: "App name is required" })
.regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }),
base64: z.string().min(1, { message: "Base64 content is required" }),
serverId: z.string().optional(),
});
type AddImport = z.infer<typeof AddImportSchema>;
type TemplateInfo = {
compose: string;
template: {
domains: Array<{
serviceName: string;
port: number;
path?: string;
host?: string;
}>;
envs: string[];
mounts: Array<{ filePath: string; content: string }>;
};
};
interface Props {
environmentId: string;
projectName?: string;
}
export const AddImport = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [mountOpen, setMountOpen] = useState(false);
const [selectedMount, setSelectedMount] = useState<{
filePath: string;
content: string;
} | null>(null);
const [templateInfo, setTemplateInfo] = useState<TemplateInfo | null>(null);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const shouldShowServerDropdown = !!(servers && servers.length > 0);
const { mutateAsync: previewTemplate, isPending: isProcessing } =
api.compose.previewTemplate.useMutation();
const { mutateAsync: createCompose, isPending: isCreating } =
api.compose.create.useMutation();
const { mutateAsync: importCompose, isPending: isImporting } =
api.compose.import.useMutation();
const form = useForm<AddImport>({
defaultValues: { name: "", appName: `${slug}-`, base64: "" },
resolver: zodResolver(AddImportSchema),
});
const resetAll = () => {
form.reset({ name: "", appName: `${slug}-`, base64: "" });
setTemplateInfo(null);
setPreviewOpen(false);
setMountOpen(false);
setSelectedMount(null);
};
const handleOpenChange = (open: boolean) => {
if (!open) resetAll();
setVisible(open);
};
const handleLoad = async (data: AddImport) => {
try {
const result = await previewTemplate({
appName: data.appName,
base64: data.base64.trim(),
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
setTemplateInfo(result);
setPreviewOpen(true);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error processing template",
);
}
};
const handleImport = async () => {
const data = form.getValues();
try {
const compose = await createCompose({
name: data.name,
appName: data.appName,
environmentId,
composeType: "docker-compose",
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
await importCompose({
composeId: compose.composeId,
base64: data.base64.trim(),
});
toast.success("Compose imported successfully");
await utils.environment.one.invalidate({ environmentId });
resetAll();
setVisible(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error importing compose",
);
}
};
const handleCancelPreview = () => {
setPreviewOpen(false);
setTemplateInfo(null);
};
return (
<>
<Dialog open={visible} onOpenChange={handleOpenChange}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<FileInput className="size-4 text-muted-foreground" />
<span>Import</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Import Compose</DialogTitle>
<DialogDescription>
Paste a base64-encoded compose export to preview and import it
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-import"
onSubmit={form.handleSubmit(handleLoad)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My App"
{...field}
onChange={(e) => {
const val = e.target.value || "";
form.setValue(
"appName",
`${slug}-${slugify(val.trim())}`,
);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
<TooltipContent
className="z-[999] w-[300px]"
align="start"
side="top"
>
<span>
If no server is selected, the compose will be
deployed on the server where the user is logged
in.
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers (
{(servers?.length ?? 0) + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base64"
render={({ field }) => (
<FormItem>
<FormLabel>Configuration (Base64)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste your base64-encoded compose export here..."
className="font-mono resize-none h-32"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
isLoading={isCreating || isProcessing}
>
Load
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Preview modal */}
<Dialog
open={previewOpen}
onOpenChange={(open) => !open && handleCancelPreview()}
>
<DialogContent className="max-w-[60vw]">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Review the template information before importing</p>
<AlertBlock type="warning">
Warning: This will remove all existing environment variables,
mounts, and domains from this service.
</AlertBlock>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Docker Compose</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
{templateInfo?.template.domains &&
templateInfo.template.domains.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Domains</h3>
</div>
<div className="grid grid-cols-1 gap-3">
{templateInfo.template.domains.map((domain, index) => (
<div
key={index}
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
>
<div className="font-medium">
{domain.serviceName}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Port: {domain.port}</div>
{domain.host && <div>Host: {domain.host}</div>}
{domain.path && <div>Path: {domain.path}</div>}
</div>
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.envs &&
templateInfo.template.envs.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Environment Variables
</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
>
{env}
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.mounts &&
templateInfo.template.mounts.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Mounts</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.mounts.map((mount, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
onClick={() => {
setSelectedMount(mount);
setMountOpen(true);
}}
>
{mount.filePath}
</div>
))}
</div>
</div>
</>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={handleCancelPreview}>
Cancel
</Button>
<Button isLoading={isImporting} onClick={handleImport}>
Import
</Button>
</div>
</DialogContent>
</Dialog>
{/* Mount content modal */}
<Dialog open={mountOpen} onOpenChange={setMountOpen}>
<DialogContent className="max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{selectedMount?.filePath}
</DialogTitle>
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[45vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}
className="font-mono"
readOnly
/>
</ScrollArea>
<div className="flex justify-end gap-2 pt-4">
<Button onClick={() => setMountOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,6 +1,6 @@
import {
Bookmark,
BookText,
Bookmark,
CheckIcon,
ChevronsUpDown,
Globe,

View File

@@ -344,7 +344,7 @@ export const ShowProjects = () => {
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border flex flex-col">
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 ">
@@ -491,7 +491,7 @@ export const ShowProjects = () => {
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4 mt-auto">
<CardFooter className="pt-4">
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created

View File

@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
redisId: string;

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { NumberInput } from "@/components/ui/input";
import {
Dialog,
DialogContent,
@@ -33,7 +34,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { NumberInput } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";

View File

@@ -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}
/>
)}

View File

@@ -1,5 +1,3 @@
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
@@ -9,6 +7,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
interface Props {
serverId?: string;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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

View File

@@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -27,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { api, type RouterOutputs } from "@/utils/api";
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */

View File

@@ -141,14 +141,14 @@ export const WebDomain = () => {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 grid-cols-2"
className="grid w-full gap-4 md:grid-cols-2"
>
<FormField
control={form.control}
name="domain"
render={({ field }) => {
return (
<FormItem className="col-span-2 md:col-span-1">
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
@@ -168,7 +168,7 @@ export const WebDomain = () => {
name="letsEncryptEmail"
render={({ field }) => {
return (
<FormItem className="col-span-2 md:col-span-1">
<FormItem>
<FormLabel>Let's Encrypt Email</FormLabel>
<FormControl>
<Input
@@ -209,7 +209,7 @@ export const WebDomain = () => {
name="certificateType"
render={({ field }) => {
return (
<FormItem className="col-span-2">
<FormItem className="md:col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}

View File

@@ -1,6 +1,4 @@
import copy from "copy-to-clipboard";
import { CopyIcon, ServerIcon } from "lucide-react";
import { toast } from "sonner";
import { ServerIcon } from "lucide-react";
import {
Card,
CardContent,
@@ -51,17 +49,8 @@ export const WebServer = () => {
</div>
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">
Server IP: {webServerSettings?.serverIp}
{webServerSettings?.serverIp && (
<CopyIcon
className="size-3.5 cursor-pointer hover:text-foreground transition-colors"
onClick={() => {
copy(webServerSettings.serverIp ?? "");
toast.success("Copied to clipboard");
}}
/>
)}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}

View File

@@ -868,19 +868,6 @@ function SidebarLogo() {
);
}
function MobileCloser() {
const pathname = usePathname();
const { setOpenMobile, isMobile } = useSidebar();
useEffect(() => {
if (isMobile) {
setOpenMobile(false);
}
}, [pathname, isMobile, setOpenMobile]);
return null;
}
export default function Page({ children }: Props) {
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
undefined,
@@ -946,7 +933,6 @@ export default function Page({ children }: Props) {
} as React.CSSProperties
}
>
<MobileCloser />
<Sidebar collapsible="icon" variant="floating">
<SidebarHeader>
{/* <SidebarMenuButton

View File

@@ -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>
);
};

View File

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

View File

@@ -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>

View File

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

View File

@@ -342,7 +342,7 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<FolderInput className="size-4 text-muted-foreground" />
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span className="font-medium max-w-[150px] truncate">
{currentProject?.name || "Select Project"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -478,7 +478,7 @@ export const AdvanceBreadcrumb = () => {
aria-expanded={environmentOpen}
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span className="font-medium max-w-[150px] truncate">
{currentEnvironment?.name || "production"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -533,7 +533,7 @@ export const AdvanceBreadcrumb = () => {
)}
{projectEnvironments && projectEnvironments.length === 1 && (
<p className="text-sm font-normal ml-1 max-w-[50px] md:max-w-[150px] truncate">
<p className="text-sm font-normal ml-1">
{currentEnvironment?.name || "production"}
</p>
)}
@@ -551,7 +551,7 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
{getServiceIcon(currentService.type)}
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
<span className="font-medium max-w-[150px] truncate">
{currentService.name}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -617,7 +617,7 @@ export const AdvanceBreadcrumb = () => {
<Button
variant="ghost"
size="icon"
className="size-7 ml-1 hidden md:flex"
className="size-7 ml-1"
onClick={() => {
router.push(
`/dashboard/project/${projectId}/environment/${environmentId}`,

View File

@@ -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({

View File

@@ -63,7 +63,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(
buttonVariants({ variant, size, className }),
"flex gap-2",
className,
)}
ref={ref}
{...props}

View File

@@ -1 +0,0 @@
ALTER TABLE "schedule" ADD COLUMN "description" text;

View File

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

View File

@@ -1 +0,0 @@
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
{
"id": "887c0c81-4af9-477a-ab29-b3ad16f08451",
"id": "0e5a80ea-2d66-4bd2-a41d-b92c419e0116",
"prevId": "ef484e78-f78d-4c3f-ae4b-aa123dc77e61",
"version": "7",
"dialect": "postgresql",
@@ -789,6 +789,13 @@
"type": "text",
"primaryKey": false,
"notNull": true
},
"verified": {
"name": "verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
@@ -6714,12 +6721,6 @@
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cronExpression": {
"name": "cronExpression",
"type": "text",
@@ -6871,6 +6872,72 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.scim_provider": {
"name": "scim_provider",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scim_token": {
"name": "scim_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"scim_provider_organization_id_organization_id_fk": {
"name": "scim_provider_organization_id_organization_id_fk",
"tableFrom": "scim_provider",
"tableTo": "organization",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"scim_provider_provider_id_unique": {
"name": "scim_provider_provider_id_unique",
"nullsNotDistinct": false,
"columns": [
"provider_id"
]
},
"scim_provider_scim_token_unique": {
"name": "scim_provider_scim_token_unique",
"nullsNotDistinct": false,
"columns": [
"scim_token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.security": {
"name": "security",
"schema": "",

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

File diff suppressed because it is too large Load Diff

View File

@@ -1167,43 +1167,8 @@
{
"idx": 166,
"version": "7",
"when": 1778303519111,
"tag": "0166_nosy_slapstick",
"breakpoints": true
},
{
"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": 1776576422440,
"tag": "0166_overjoyed_big_bertha",
"breakpoints": true
}
]

View File

@@ -28,7 +28,6 @@ try {
"wait-for-postgres": "wait-for-postgres.ts",
"reset-password": "reset-password.ts",
"reset-2fa": "reset-2fa.ts",
"migrate-auth-secret": "scripts/migrate-auth-secret.ts",
},
bundle: true,
platform: "node",

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.8",
"version": "v0.29.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -14,7 +14,6 @@
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
"migrate-auth-secret": "node -r dotenv/config dist/migrate-auth-secret.mjs",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
@@ -47,8 +46,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@better-auth/api-key": "1.6.5",
"@better-auth/sso": "1.6.5",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
@@ -102,7 +101,7 @@
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.5.4",
"better-auth": "1.6.5",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.67.3",
@@ -114,7 +113,7 @@
"dockerode": "4.0.2",
"dompurify": "^3.3.3",
"dotenv": "16.4.5",
"drizzle-orm": "0.45.1",
"drizzle-orm": "0.45.2",
"drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",
@@ -123,11 +122,11 @@
"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",
"node-pty": "1.1.0",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
@@ -148,7 +147,7 @@
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "~1.16.0",
"ssh2": "1.15.0",
"stripe": "17.2.0",
"superjson": "^2.2.2",
"swagger-ui-react": "^5.31.2",

View File

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

View File

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

View File

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

View File

@@ -84,7 +84,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

View File

@@ -95,7 +95,7 @@ export async function getServerSideProps(
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/dashboard/home",
},
};
@@ -104,7 +104,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: false,
permanent: true,
destination: "/",
},
};

Some files were not shown because too many files have changed in this diff Show More