mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 22:25:22 +02:00
Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c2e1e5af | ||
|
|
60867d0b60 | ||
|
|
6a0acd9cad | ||
|
|
64a606ffa4 | ||
|
|
29851491f6 | ||
|
|
95633b4122 | ||
|
|
c73632cbe0 | ||
|
|
30b3e1fe48 | ||
|
|
a07106d649 | ||
|
|
df98cea19f | ||
|
|
b109e0ebc4 | ||
|
|
282d358d04 | ||
|
|
2f08b33931 | ||
|
|
ccc8f6d047 | ||
|
|
62aeed5aed | ||
|
|
5e021797f3 | ||
|
|
1c6fdc1b43 | ||
|
|
6270bad9af | ||
|
|
9c71458eff | ||
|
|
547ba2d04b | ||
|
|
b9e97eb321 | ||
|
|
a4e2317f3e | ||
|
|
06a349152f | ||
|
|
fef2de1ec5 | ||
|
|
b20ff64cbf | ||
|
|
5177580d51 | ||
|
|
d3292a2810 | ||
|
|
0f526af2c8 | ||
|
|
72f5d711c8 | ||
|
|
ffd51cf32f | ||
|
|
e8b3d7ba7d | ||
|
|
c182755591 | ||
|
|
8227a48ef4 | ||
|
|
f5ddc36f24 | ||
|
|
d5d8914bf6 | ||
|
|
bf0890a6b0 | ||
|
|
4e07669464 | ||
|
|
4a3fa6e63f | ||
|
|
14af5d293a | ||
|
|
746bb3ddc6 | ||
|
|
b13308dc69 | ||
|
|
16746a1609 | ||
|
|
bca62d43d2 | ||
|
|
d502f4a206 | ||
|
|
de7d6f8147 | ||
|
|
9d6bc4cd18 | ||
|
|
65b27af0f5 | ||
|
|
6165114bc3 | ||
|
|
d3109359fb | ||
|
|
58f527d029 | ||
|
|
1ed41fe2f8 | ||
|
|
9b416b3699 | ||
|
|
096b8b33fc | ||
|
|
741792883a | ||
|
|
e0c6ed699d | ||
|
|
5f5ed0f2c2 | ||
|
|
b9ff576682 | ||
|
|
c854a38adb | ||
|
|
5fb365c08b | ||
|
|
15296d5c85 | ||
|
|
0e5fc584b2 | ||
|
|
cc7ea5108b | ||
|
|
8f3d824ea6 | ||
|
|
0bdcbf5827 | ||
|
|
34564aec84 | ||
|
|
ed006dc5f9 | ||
|
|
222b167a76 | ||
|
|
ad490dca3f | ||
|
|
ce703ef478 | ||
|
|
fc6df3ae05 | ||
|
|
8fb517152a | ||
|
|
ba3591b3ac | ||
|
|
bad9731878 | ||
|
|
7e13243c1d | ||
|
|
4d8a2a38e8 | ||
|
|
de3db08e60 | ||
|
|
a2d655083a | ||
|
|
f3356cfe90 | ||
|
|
2362778fe1 | ||
|
|
628f16e8cb | ||
|
|
ea8e99d76d | ||
|
|
d4719ece58 | ||
|
|
e679a322b9 | ||
|
|
f24f1ada5f | ||
|
|
5b6d80e177 | ||
|
|
2c9ca651a8 | ||
|
|
413ed9bd80 | ||
|
|
4f578516d6 | ||
|
|
1e57d48ab4 | ||
|
|
a177d34dfd | ||
|
|
1034c79245 | ||
|
|
304454b22d | ||
|
|
42c2076281 | ||
|
|
5cd7de8188 | ||
|
|
1352b859e2 | ||
|
|
1c2307b86f | ||
|
|
4832fd929c | ||
|
|
d1b639a55a | ||
|
|
40de13e4d4 | ||
|
|
f0ea1c8796 | ||
|
|
b45e7e415c | ||
|
|
67d3e92aaf | ||
|
|
76af74d8aa | ||
|
|
b15ede8877 | ||
|
|
ea805c1520 | ||
|
|
976932fb03 | ||
|
|
ac8960efdd | ||
|
|
d6050ce05a | ||
|
|
5a46b879f5 | ||
|
|
222e4878bd | ||
|
|
fd267a64de | ||
|
|
fa3cdf148b | ||
|
|
74caf141f4 | ||
|
|
8b7d9c0896 | ||
|
|
13e20e9ef8 | ||
|
|
f9b0589070 | ||
|
|
b615d04ad2 | ||
|
|
6c4efa48b1 | ||
|
|
85d48aba2b | ||
|
|
3b138f8e8a | ||
|
|
b91067dc2a | ||
|
|
335a16b915 | ||
|
|
274f38029c | ||
|
|
4cbc91d3d0 | ||
|
|
10d17de186 | ||
|
|
65f0919fa7 | ||
|
|
9b7abfbed7 | ||
|
|
6676a86b34 | ||
|
|
d603654ac1 | ||
|
|
d9ffe519b0 | ||
|
|
fa91a74462 | ||
|
|
d7794286be | ||
|
|
f337dd7e01 | ||
|
|
5d5d95bbd3 | ||
|
|
7be1084a10 | ||
|
|
19a525fac1 | ||
|
|
7984497398 |
79
.github/workflows/dokploy.yml
vendored
79
.github/workflows/dokploy.yml
vendored
@@ -138,6 +138,8 @@ jobs:
|
|||||||
needs: [combine-manifests]
|
needs: [combine-manifests]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -160,3 +162,80 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
21
.github/workflows/pr-quality.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
|
|
||||||
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
|
|
||||||
21
.github/workflows/sync-openapi-docs.yml
vendored
21
.github/workflows/sync-openapi-docs.yml
vendored
@@ -110,3 +110,24 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
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"
|
||||||
|
|
||||||
|
|||||||
83
.github/workflows/sync-version.yml
vendored
83
.github/workflows/sync-version.yml
vendored
@@ -1,83 +0,0 @@
|
|||||||
name: Sync version to MCP and CLI repos
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
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 }}"
|
|
||||||
|
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,8 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.biome": "explicit",
|
||||||
"source.organizeImports.biome": "explicit"
|
"source.organizeImports.biome": "explicit"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||||
|
|
||||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||||
|
|||||||
52
apps/dokploy/__test__/compose/build-compose-command.test.ts
Normal file
52
apps/dokploy/__test__/compose/build-compose-command.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Isolate the command builder from the compose-file I/O performed by
|
||||||
|
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
||||||
|
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||||
|
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseCompose = {
|
||||||
|
appName: "my-app",
|
||||||
|
sourceType: "raw",
|
||||||
|
command: "",
|
||||||
|
composePath: "docker-compose.yml",
|
||||||
|
composeType: "stack",
|
||||||
|
isolatedDeployment: false,
|
||||||
|
randomize: false,
|
||||||
|
suffix: "",
|
||||||
|
serverId: null,
|
||||||
|
env: "",
|
||||||
|
mounts: [],
|
||||||
|
domains: [],
|
||||||
|
environment: { project: { env: "" }, env: "" },
|
||||||
|
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
||||||
|
|
||||||
|
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
||||||
|
// clears the environment except for the vars listed explicitly. HOME must be
|
||||||
|
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
||||||
|
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
||||||
|
// and private-registry images fail to pull.
|
||||||
|
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
||||||
|
it("preserves HOME for swarm stack deploys", async () => {
|
||||||
|
const command = await getBuildComposeCommand({
|
||||||
|
...baseCompose,
|
||||||
|
composeType: "stack",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command).toContain("stack deploy");
|
||||||
|
expect(command).toContain("--with-registry-auth");
|
||||||
|
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves HOME for docker compose deploys", async () => {
|
||||||
|
const command = await getBuildComposeCommand({
|
||||||
|
...baseCompose,
|
||||||
|
composeType: "docker-compose",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command).toContain("compose -p my-app");
|
||||||
|
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ describe("Host rule format regression tests", () => {
|
|||||||
stripPath: false,
|
stripPath: false,
|
||||||
customEntrypoint: null,
|
customEntrypoint: null,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
|
forwardAuthEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
describe("Host rule format validation", () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe("createDomainLabels", () => {
|
|||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
|
forwardAuthEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
@@ -103,6 +104,51 @@ 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 () => {
|
it("should handle different ports correctly", async () => {
|
||||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||||
|
|||||||
41
apps/dokploy/__test__/deploy/should-deploy.test.ts
Normal file
41
apps/dokploy/__test__/deploy/should-deploy.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
369
apps/dokploy/__test__/git-provider/git-provider-access.test.ts
Normal file
369
apps/dokploy/__test__/git-provider/git-provider-access.test.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
canEditDeployGitSource,
|
||||||
|
getAccessibleGitProviderIds,
|
||||||
|
} from "@dokploy/server/services/git-provider";
|
||||||
|
|
||||||
|
const mockDb = vi.hoisted(() => ({
|
||||||
|
query: {
|
||||||
|
gitProvider: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
||||||
|
|
||||||
|
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||||
|
hasValidLicense: mockHasValidLicense,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ORG_ID = "org-1";
|
||||||
|
const USER_OWNER = "user-owner";
|
||||||
|
const USER_ADMIN = "user-admin";
|
||||||
|
const USER_MEMBER = "user-member";
|
||||||
|
const USER_MEMBER_2 = "user-member-2";
|
||||||
|
|
||||||
|
const providerOwned = {
|
||||||
|
gitProviderId: "gp-owned",
|
||||||
|
userId: USER_MEMBER,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
};
|
||||||
|
const providerShared = {
|
||||||
|
gitProviderId: "gp-shared",
|
||||||
|
userId: USER_OWNER,
|
||||||
|
sharedWithOrganization: true,
|
||||||
|
};
|
||||||
|
const providerPrivate = {
|
||||||
|
gitProviderId: "gp-private",
|
||||||
|
userId: USER_OWNER,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
};
|
||||||
|
const providerOtherMember = {
|
||||||
|
gitProviderId: "gp-other",
|
||||||
|
userId: USER_MEMBER_2,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const allProviders = [
|
||||||
|
providerOwned,
|
||||||
|
providerShared,
|
||||||
|
providerPrivate,
|
||||||
|
providerOtherMember,
|
||||||
|
];
|
||||||
|
|
||||||
|
function session(userId: string) {
|
||||||
|
return { userId, activeOrganizationId: ORG_ID };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
|
||||||
|
mockHasValidLicense.mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAccessibleGitProviderIds", () => {
|
||||||
|
describe("owner", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "owner",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all org providers", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||||
|
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes providers owned by other members", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
||||||
|
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||||
|
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "admin",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all org providers", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||||
|
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes providers owned by other members — fixes issue #4469", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||||
|
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||||
|
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("member without enterprise license", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||||
|
});
|
||||||
|
mockHasValidLicense.mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can access their own provider", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can access shared providers", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot access private providers of other users even if assigned (no license)", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot access providers of other members", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("member with enterprise license", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHasValidLicense.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can access provider explicitly assigned to them", async () => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||||
|
});
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot access provider not assigned and not shared", async () => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||||
|
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can access shared provider even without explicit assignment", async () => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can access own provider regardless of assignments", async () => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot access provider of other member even with license but no assignment", async () => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("member with no member record", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue(null);
|
||||||
|
mockHasValidLicense.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only returns own providers and shared ones", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
||||||
|
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
||||||
|
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enterprise license — member assigned to a provider they do not own", () => {
|
||||||
|
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
|
||||||
|
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
|
||||||
|
mockHasValidLicense.mockResolvedValue(true);
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [providerPrivate.gitProviderId],
|
||||||
|
});
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
|
||||||
|
mockHasValidLicense.mockResolvedValue(true);
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "member",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
||||||
|
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("empty org", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({
|
||||||
|
role: "admin",
|
||||||
|
accessedGitProviders: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty set when org has no providers", async () => {
|
||||||
|
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
||||||
|
expect(ids.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canEditDeployGitSource", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockHasValidLicense.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("owner", () => {
|
||||||
|
it("can edit deploy using any provider", async () => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
providerPrivate.gitProviderId,
|
||||||
|
session(USER_OWNER),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot edit deploy using owner's private provider (not shared)", async () => {
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||||
|
userId: USER_OWNER,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
});
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
providerPrivate.gitProviderId,
|
||||||
|
session(USER_ADMIN),
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can edit deploy using a provider shared with the org", async () => {
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||||
|
userId: USER_OWNER,
|
||||||
|
sharedWithOrganization: true,
|
||||||
|
});
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
providerShared.gitProviderId,
|
||||||
|
session(USER_ADMIN),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can edit deploy using their own provider", async () => {
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||||
|
userId: USER_ADMIN,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
});
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
"gp-admin-owned",
|
||||||
|
session(USER_ADMIN),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("member", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can edit deploy using their own provider", async () => {
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||||
|
userId: USER_MEMBER,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
});
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
providerOwned.gitProviderId,
|
||||||
|
session(USER_MEMBER),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can edit deploy using a provider shared with the org", async () => {
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||||
|
userId: USER_OWNER,
|
||||||
|
sharedWithOrganization: true,
|
||||||
|
});
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
providerShared.gitProviderId,
|
||||||
|
session(USER_MEMBER),
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
|
||||||
|
// This is the key case: enterprise, provider del owner, no compartido,
|
||||||
|
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||||
|
userId: USER_OWNER,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
});
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
providerPrivate.gitProviderId,
|
||||||
|
session(USER_MEMBER),
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot edit deploy using another member's private provider", async () => {
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
||||||
|
userId: USER_MEMBER_2,
|
||||||
|
sharedWithOrganization: false,
|
||||||
|
});
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
providerOtherMember.gitProviderId,
|
||||||
|
session(USER_MEMBER),
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false if provider does not exist", async () => {
|
||||||
|
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
|
||||||
|
const result = await canEditDeployGitSource(
|
||||||
|
"nonexistent-id",
|
||||||
|
session(USER_MEMBER),
|
||||||
|
);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("static roles bypass enterprise resources", () => {
|
describe("owner and admin bypass enterprise resources", () => {
|
||||||
it("owner bypasses deployment.read", async () => {
|
it("owner bypasses deployment.read", async () => {
|
||||||
memberToReturn = mockMemberData("owner");
|
memberToReturn = mockMemberData("owner");
|
||||||
await expect(
|
await expect(
|
||||||
@@ -73,15 +73,8 @@ describe("static roles bypass enterprise resources", () => {
|
|||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("member bypasses schedule.delete", async () => {
|
it("owner bypasses multiple enterprise permissions at once", async () => {
|
||||||
memberToReturn = mockMemberData("member");
|
memberToReturn = mockMemberData("owner");
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { schedule: ["delete"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
await expect(
|
||||||
checkPermission(ctx, {
|
checkPermission(ctx, {
|
||||||
deployment: ["read"],
|
deployment: ["read"],
|
||||||
@@ -92,6 +85,55 @@ describe("static roles 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", () => {
|
describe("static roles validate free-tier resources", () => {
|
||||||
it("owner passes project.create", async () => {
|
it("owner passes project.create", async () => {
|
||||||
memberToReturn = mockMemberData("owner");
|
memberToReturn = mockMemberData("owner");
|
||||||
|
|||||||
@@ -494,4 +494,49 @@ describe("processTemplate", () => {
|
|||||||
expect(result.mounts).toHaveLength(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ describe("helpers functions", () => {
|
|||||||
const domain = processValue("${domain}", {}, mockSchema);
|
const domain = processValue("${domain}", {}, mockSchema);
|
||||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
domain.endsWith(
|
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
|
||||||
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
|
||||||
),
|
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
233
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
233
apps/dokploy/__test__/traefik/forward-auth.test.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import type { ApplicationNested, Domain } from "@dokploy/server";
|
||||||
|
import {
|
||||||
|
buildForwardAuthEnv,
|
||||||
|
createRouterConfig,
|
||||||
|
deriveBaseDomain,
|
||||||
|
deriveCookieSecret,
|
||||||
|
forwardAuthCallbackUrl,
|
||||||
|
forwardAuthMiddlewareName,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { beforeAll, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
appName: "my-app",
|
||||||
|
redirects: [],
|
||||||
|
security: [],
|
||||||
|
} as unknown as ApplicationNested;
|
||||||
|
|
||||||
|
const baseDomain: Domain = {
|
||||||
|
applicationId: "app-1",
|
||||||
|
certificateType: "none",
|
||||||
|
createdAt: "",
|
||||||
|
domainId: "domain-1",
|
||||||
|
host: "app.example.com",
|
||||||
|
https: false,
|
||||||
|
path: null,
|
||||||
|
port: 3000,
|
||||||
|
customEntrypoint: null,
|
||||||
|
serviceName: "",
|
||||||
|
composeId: "",
|
||||||
|
customCertResolver: null,
|
||||||
|
domainType: "application",
|
||||||
|
uniqueConfigKey: 7,
|
||||||
|
previewDeploymentId: "",
|
||||||
|
internalPath: "/",
|
||||||
|
stripPath: false,
|
||||||
|
middlewares: null,
|
||||||
|
forwardAuthEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("forwardAuthMiddlewareName", () => {
|
||||||
|
test("is stable and unique per app + uniqueConfigKey", () => {
|
||||||
|
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||||
|
"forward-auth-my-app-7",
|
||||||
|
);
|
||||||
|
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
||||||
|
forwardAuthMiddlewareName("my-app", 7),
|
||||||
|
);
|
||||||
|
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
|
||||||
|
forwardAuthMiddlewareName("my-app", 8),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRouterConfig forward-auth wiring", () => {
|
||||||
|
test("does NOT add forward-auth middleware when no provider is linked", async () => {
|
||||||
|
const config = await createRouterConfig(app, baseDomain, "websecure");
|
||||||
|
expect(config.middlewares).not.toContain(
|
||||||
|
forwardAuthMiddlewareName("my-app", 7),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds forward-auth middleware when a provider is linked", async () => {
|
||||||
|
const domain: Domain = {
|
||||||
|
...baseDomain,
|
||||||
|
forwardAuthEnabled: true,
|
||||||
|
};
|
||||||
|
const config = await createRouterConfig(app, domain, "websecure");
|
||||||
|
expect(config.middlewares).toContain(
|
||||||
|
forwardAuthMiddlewareName("my-app", 7),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forward-auth runs before custom domain middlewares", async () => {
|
||||||
|
const domain: Domain = {
|
||||||
|
...baseDomain,
|
||||||
|
forwardAuthEnabled: true,
|
||||||
|
middlewares: ["rate-limit@file"],
|
||||||
|
};
|
||||||
|
const config = await createRouterConfig(app, domain, "websecure");
|
||||||
|
const forwardAuthIdx = config.middlewares?.indexOf(
|
||||||
|
forwardAuthMiddlewareName("my-app", 7),
|
||||||
|
);
|
||||||
|
const customIdx = config.middlewares?.indexOf("rate-limit@file");
|
||||||
|
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirect-only web router does not get the forward-auth middleware", async () => {
|
||||||
|
const domain: Domain = {
|
||||||
|
...baseDomain,
|
||||||
|
https: true,
|
||||||
|
forwardAuthEnabled: true,
|
||||||
|
};
|
||||||
|
const config = await createRouterConfig(app, domain, "web");
|
||||||
|
expect(config.middlewares).toContain("redirect-to-https");
|
||||||
|
expect(config.middlewares).not.toContain(
|
||||||
|
forwardAuthMiddlewareName("my-app", 7),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildForwardAuthEnv", () => {
|
||||||
|
const baseOptions = {
|
||||||
|
oidc: {
|
||||||
|
clientId: "client-123",
|
||||||
|
clientSecret: "secret-xyz",
|
||||||
|
issuer: "https://idp.example.com",
|
||||||
|
},
|
||||||
|
cookieSecret: "cookie-secret-value",
|
||||||
|
authDomain: "auth.acme.com",
|
||||||
|
baseDomain: ".acme.com",
|
||||||
|
authDomainHttps: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("emits the required oauth2-proxy OIDC env vars", () => {
|
||||||
|
const env = buildForwardAuthEnv(baseOptions);
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
|
||||||
|
expect(env).toContain(
|
||||||
|
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
|
||||||
|
);
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the central auth domain for the single fixed callback", () => {
|
||||||
|
const env = buildForwardAuthEnv(baseOptions);
|
||||||
|
expect(env).toContain(
|
||||||
|
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
|
||||||
|
const env = buildForwardAuthEnv(baseOptions);
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches cookie Secure flag and callback scheme to https setting", () => {
|
||||||
|
const https = buildForwardAuthEnv(baseOptions);
|
||||||
|
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
|
||||||
|
|
||||||
|
const http = buildForwardAuthEnv({
|
||||||
|
...baseOptions,
|
||||||
|
authDomainHttps: false,
|
||||||
|
});
|
||||||
|
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
|
||||||
|
expect(http).toContain(
|
||||||
|
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
|
||||||
|
const env = buildForwardAuthEnv(baseOptions);
|
||||||
|
expect(env).toContain(
|
||||||
|
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to any authenticated user and standard scopes", () => {
|
||||||
|
const env = buildForwardAuthEnv(baseOptions);
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("honors custom scopes and email domains", () => {
|
||||||
|
const env = buildForwardAuthEnv({
|
||||||
|
...baseOptions,
|
||||||
|
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
|
||||||
|
emailDomains: ["acme.com", "corp.com"],
|
||||||
|
});
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
|
||||||
|
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets skip-discovery flag only when requested", () => {
|
||||||
|
const withoutSkip = buildForwardAuthEnv(baseOptions);
|
||||||
|
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||||
|
|
||||||
|
const withSkip = buildForwardAuthEnv({
|
||||||
|
...baseOptions,
|
||||||
|
oidc: { ...baseOptions.oidc, skipDiscovery: true },
|
||||||
|
});
|
||||||
|
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveBaseDomain", () => {
|
||||||
|
test("strips the auth subdomain to the shared base", () => {
|
||||||
|
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
|
||||||
|
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a two-label apex as the base", () => {
|
||||||
|
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("forwardAuthCallbackUrl", () => {
|
||||||
|
test("builds the single IdP callback per scheme", () => {
|
||||||
|
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
|
||||||
|
"https://auth.acme.com/oauth2/callback",
|
||||||
|
);
|
||||||
|
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
|
||||||
|
"http://auth.acme.com/oauth2/callback",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveCookieSecret", () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.BETTER_AUTH_SECRET = "test-root-secret";
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is deterministic for the same salt (survives service updates)", () => {
|
||||||
|
expect(deriveCookieSecret(".acme.com")).toBe(
|
||||||
|
deriveCookieSecret(".acme.com"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("differs per salt", () => {
|
||||||
|
expect(deriveCookieSecret(".acme.com")).not.toBe(
|
||||||
|
deriveCookieSecret(".other.com"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
|
||||||
|
const secret = deriveCookieSecret(".acme.com");
|
||||||
|
expect(Buffer.from(secret, "hex")).toHaveLength(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,8 @@ const baseSettings: WebServerSettings = {
|
|||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
|
remoteServersOnly: false,
|
||||||
|
enforceSSO: false,
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ const baseDomain: Domain = {
|
|||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
|
forwardAuthEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
|
|||||||
@@ -78,4 +78,20 @@ describe("readValidDirectory (path traversal)", () => {
|
|||||||
it("returns false for empty string (resolves to cwd)", () => {
|
it("returns false for empty string (resolves to cwd)", () => {
|
||||||
expect(readValidDirectory("")).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
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({
|
export const healthCheckFormSchema = z.object({
|
||||||
Test: z.array(z.string()).optional(),
|
Test: z.array(z.string()).optional(),
|
||||||
Interval: z.coerce.number().optional(),
|
Interval: optionalNumber,
|
||||||
Timeout: z.coerce.number().optional(),
|
Timeout: optionalNumber,
|
||||||
StartPeriod: z.coerce.number().optional(),
|
StartPeriod: optionalNumber,
|
||||||
Retries: z.coerce.number().optional(),
|
Retries: optionalNumber,
|
||||||
});
|
});
|
||||||
|
|
||||||
interface HealthCheckFormProps {
|
interface HealthCheckFormProps {
|
||||||
@@ -195,7 +200,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="10000000000" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="10000000000"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -212,7 +222,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Maximum time to wait for health check response
|
Maximum time to wait for health check response
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="10000000000" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="10000000000"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -229,7 +244,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Initial grace period before health checks begin
|
Initial grace period before health checks begin
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="10000000000" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="10000000000"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -247,7 +267,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
unhealthy
|
unhealthy
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="3" {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="3"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Reservation</FormLabel>
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Limit</FormLabel>
|
<FormLabel>CPU Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Reservation</FormLabel>
|
<FormLabel>CPU Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
<TooltipContent className="max-w-xs">
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
import type { ValidationStates } from "./show-domains";
|
|
||||||
import { AddDomain } from "./handle-domain";
|
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
|
import { AddDomain } from "./handle-domain";
|
||||||
|
import type { ValidationStates } from "./show-domains";
|
||||||
|
|
||||||
export type Domain =
|
export type Domain =
|
||||||
| RouterOutputs["domain"]["byApplicationId"][0]
|
| RouterOutputs["domain"]["byApplicationId"][0]
|
||||||
@@ -168,7 +168,7 @@ export const createColumns = ({
|
|||||||
{domain.certificateType}
|
{domain.certificateType}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{!domain.host.includes("traefik.me") && (
|
{!domain.host.includes("sslip.io") && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -256,7 +256,7 @@ export const createColumns = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!domain.host.includes("traefik.me") && (
|
{!domain.host.includes("sslip.io") && (
|
||||||
<DnsHelperModal
|
<DnsHelperModal
|
||||||
domain={{
|
domain={{
|
||||||
host: domain.host,
|
host: domain.host,
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -513,7 +513,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{!canGenerateTraefikMeDomains &&
|
{!canGenerateTraefikMeDomains &&
|
||||||
field.value.includes("traefik.me") && (
|
field.value.includes("sslip.io") && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
@@ -524,12 +524,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to make your traefik.me domain work.
|
to make your sslip.io domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP
|
<strong>Note:</strong> sslip.io is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -567,7 +567,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-[10rem]"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate traefik.me domain</p>
|
<p>Generate sslip.io domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -806,7 +806,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
<FormLabel>Middlewares</FormLabel>
|
<FormLabel>Middlewares</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
domainId: string;
|
||||||
|
applicationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: haveValidLicense } =
|
||||||
|
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data: status } = api.forwardAuth.status.useQuery(
|
||||||
|
{ domainId },
|
||||||
|
{ enabled: isOpen },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: enable, isPending: isEnabling } =
|
||||||
|
api.forwardAuth.enable.useMutation();
|
||||||
|
const { mutateAsync: disable, isPending: isDisabling } =
|
||||||
|
api.forwardAuth.disable.useMutation();
|
||||||
|
|
||||||
|
if (!haveValidLicense) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEnabled = !!status?.enabled;
|
||||||
|
const isPending = isEnabling || isDisabling;
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
await utils.forwardAuth.status.invalidate({ domainId });
|
||||||
|
await utils.domain.byApplicationId.invalidate({ applicationId });
|
||||||
|
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = async (next: boolean) => {
|
||||||
|
try {
|
||||||
|
if (next) {
|
||||||
|
await enable({ domainId });
|
||||||
|
toast.success("SSO authentication enabled for this domain");
|
||||||
|
} else {
|
||||||
|
await disable({ domainId });
|
||||||
|
toast.success("SSO authentication disabled for this domain");
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Error updating SSO authentication",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-emerald-500/10"
|
||||||
|
title="SSO authentication"
|
||||||
|
>
|
||||||
|
<ShieldCheck
|
||||||
|
className={`size-4 ${
|
||||||
|
isEnabled
|
||||||
|
? "text-emerald-500"
|
||||||
|
: "text-primary group-hover:text-emerald-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>SSO Authentication</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Require visitors to authenticate against your identity provider
|
||||||
|
before reaching this application.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium">Requirements</span>
|
||||||
|
<ol className="list-decimal pl-4 text-sm">
|
||||||
|
<li>
|
||||||
|
The authentication proxy container must be deployed and running
|
||||||
|
on this app's server. Configure it under{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
Settings → SSO → Application Authentication
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
This domain must share the same base domain as the
|
||||||
|
authentication domain (e.g. <code>app.acme.com</code> and{" "}
|
||||||
|
<code>auth.acme.com</code>).
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Protect this domain with SSO
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isEnabled
|
||||||
|
? "Visitors must log in via your identity provider."
|
||||||
|
: "The domain is publicly accessible."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
disabled={isPending}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -62,6 +62,7 @@ import { api } from "@/utils/api";
|
|||||||
import { createColumns } from "./columns";
|
import { createColumns } from "./columns";
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { AddDomain } from "./handle-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
|
import { HandleForwardAuth } from "./handle-forward-auth";
|
||||||
|
|
||||||
export type ValidationState = {
|
export type ValidationState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -425,7 +426,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{!item.host.includes("traefik.me") && (
|
{!item.host.includes("sslip.io") && (
|
||||||
<DnsHelperModal
|
<DnsHelperModal
|
||||||
domain={{
|
domain={{
|
||||||
host: item.host,
|
host: item.host,
|
||||||
@@ -453,6 +454,12 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</AddDomain>
|
</AddDomain>
|
||||||
)}
|
)}
|
||||||
|
{canCreateDomain && type === "application" && (
|
||||||
|
<HandleForwardAuth
|
||||||
|
domainId={item.domainId}
|
||||||
|
applicationId={id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canDeleteDomain && (
|
{canDeleteDomain && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Domain"
|
title="Delete Domain"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z.string().min(1, "Branch required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -107,110 +111,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
|
||||||
className="flex flex-col gap-4"
|
<FormField
|
||||||
>
|
control={form.control}
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
name="repositoryURL"
|
||||||
<div className="flex items-end col-span-2 gap-4">
|
render={({ field }) => (
|
||||||
<div className="grow">
|
<FormItem className="col-span-2 lg:col-span-3">
|
||||||
<FormField
|
<div className="flex items-center justify-between h-5">
|
||||||
control={form.control}
|
<FormLabel>Repository URL</FormLabel>
|
||||||
name="repositoryURL"
|
{field.value?.startsWith("https://") && (
|
||||||
render={({ field }) => (
|
<Link
|
||||||
<FormItem>
|
href={field.value}
|
||||||
<div className="flex items-center justify-between">
|
target="_blank"
|
||||||
<FormLabel>Repository URL</FormLabel>
|
rel="noopener noreferrer"
|
||||||
{field.value?.startsWith("https://") && (
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
<Link
|
>
|
||||||
href={field.value}
|
<GitIcon className="h-4 w-4" />
|
||||||
target="_blank"
|
<span>View Repository</span>
|
||||||
rel="noopener noreferrer"
|
</Link>
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
)}
|
||||||
>
|
</div>
|
||||||
<GitIcon className="h-4 w-4" />
|
<FormControl>
|
||||||
<span>View Repository</span>
|
<Input placeholder="Repository URL" {...field} />
|
||||||
</Link>
|
</FormControl>
|
||||||
)}
|
<FormMessage />
|
||||||
</div>
|
</FormItem>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
<div className="space-y-4">
|
{sshKeys && sshKeys.length > 0 ? (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="branch"
|
name="sshKey"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="col-span-2 lg:col-span-1">
|
||||||
<FormLabel>Branch</FormLabel>
|
<FormLabel className="w-full inline-flex justify-between">
|
||||||
|
SSH Key
|
||||||
|
<LockIcon className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Branch" {...field} />
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="buildPath"
|
name="buildPath"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="col-span-2">
|
||||||
<FormLabel>Build Path</FormLabel>
|
<FormLabel>Build Path</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="/" {...field} />
|
<Input placeholder="/" {...field} />
|
||||||
@@ -223,7 +220,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="watchPaths"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="col-span-2 lg:col-span-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -72,7 +73,10 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).default([]),
|
watchPaths: z.array(z.string()).default([]),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
|||||||
id: z.number().nullable(),
|
id: z.number().nullable(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
{canDeploy && (
|
{canDeploy && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
|
||||||
>
|
>
|
||||||
<Terminal className="size-4 mr-1" />
|
<Terminal className="size-4 mr-1" />
|
||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
{canUpdateService && (
|
{canUpdateService && (
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
<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">
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
<Switch
|
<Switch
|
||||||
aria-label="Toggle autodeploy"
|
aria-label="Toggle autodeploy"
|
||||||
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canUpdateService && (
|
{canUpdateService && (
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
<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">
|
||||||
<span className="text-sm font-medium">Clean Cache</span>
|
<span className="text-sm font-medium">Clean Cache</span>
|
||||||
<Switch
|
<Switch
|
||||||
aria-label="Toggle clean cache"
|
aria-label="Toggle clean cache"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP
|
<strong>Note:</strong> sslip.io is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
|
|||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-[10rem]"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate traefik.me domain</p>
|
<p>Generate sslip.io domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
wildcardDomain: "*.traefik.me",
|
wildcardDomain: "*.sslip.io",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
previewLimit: 3,
|
previewLimit: 3,
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
const wildcardDomain = form.watch("wildcardDomain");
|
const wildcardDomain = form.watch("wildcardDomain");
|
||||||
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
env: data.previewEnv || "",
|
env: data.previewEnv || "",
|
||||||
buildArgs: data.previewBuildArgs || "",
|
buildArgs: data.previewBuildArgs || "",
|
||||||
buildSecrets: data.previewBuildSecrets || "",
|
buildSecrets: data.previewBuildSecrets || "",
|
||||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
wildcardDomain: data.previewWildcard || "*.sslip.io",
|
||||||
port: data.previewPort || 3000,
|
port: data.previewPort || 3000,
|
||||||
previewLabels: data.previewLabels || [],
|
previewLabels: data.previewLabels || [],
|
||||||
previewLimit: data.previewLimit || 3,
|
previewLimit: data.previewLimit || 3,
|
||||||
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP service and
|
<strong>Note:</strong> sslip.io is a public HTTP service and
|
||||||
does not support SSL/HTTPS. HTTPS and certificate options will
|
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||||
not have any effect.
|
not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Wildcard Domain</FormLabel>
|
<FormLabel>Wildcard Domain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="*.traefik.me" {...field} />
|
<Input placeholder="*.sslip.io" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const commonCronExpressions = [
|
|||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
shellType: z.enum(["bash", "sh"]).default("bash"),
|
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
@@ -224,6 +225,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
resolver: standardSchemaResolver(formSchema),
|
resolver: standardSchemaResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
description: "",
|
||||||
cronExpression: "",
|
cronExpression: "",
|
||||||
shellType: "bash",
|
shellType: "bash",
|
||||||
command: "",
|
command: "",
|
||||||
@@ -263,6 +265,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
if (scheduleId && schedule) {
|
if (scheduleId && schedule) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: schedule.name,
|
name: schedule.name,
|
||||||
|
description: schedule.description || "",
|
||||||
cronExpression: schedule.cronExpression,
|
cronExpression: schedule.cronExpression,
|
||||||
shellType: schedule.shellType,
|
shellType: schedule.shellType,
|
||||||
command: schedule.command,
|
command: schedule.command,
|
||||||
@@ -479,6 +482,26 @@ 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
|
<ScheduleFormField
|
||||||
name="cronExpression"
|
name="cronExpression"
|
||||||
formControl={form.control}
|
formControl={form.control}
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -36,10 +40,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { api } from "@/utils/api";
|
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(
|
const DockerLogsId = dynamic(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
const composeFile = form.watch("composeFile");
|
const composeFile = form.watch("composeFile");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && !composeFile) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
composeFile: data.composeFile || "",
|
composeFile: data.composeFile || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.composeFile !== undefined) {
|
if (data?.composeFile !== undefined) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -418,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z.string().min(1, "Branch required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -57,7 +58,10 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
@@ -445,7 +449,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
|||||||
gitlabPathNamespace: z.string().min(1),
|
gitlabPathNamespace: z.string().min(1),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Branch is required")
|
||||||
|
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -436,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger type="button">
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
?
|
?
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ export const RestoreBackup = ({
|
|||||||
toast.error("Please select a database type");
|
toast.error("Please select a database type");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log({ data });
|
|
||||||
setIsDeploying(true);
|
setIsDeploying(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
|
import copy from "copy-to-clipboard";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Check,
|
||||||
|
Copy,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
@@ -30,6 +39,7 @@ const MAX_LOG_LINES = 200;
|
|||||||
export function AnalyzeLogs({ logs, context }: Props) {
|
export function AnalyzeLogs({ logs, context }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [aiId, setAiId] = useState<string>("");
|
const [aiId, setAiId] = useState<string>("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
@@ -52,6 +62,15 @@ export function AnalyzeLogs({ logs, context }: Props) {
|
|||||||
mutate({ aiId, logs: logsText, context });
|
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 (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
open={open}
|
open={open}
|
||||||
@@ -71,7 +90,7 @@ export function AnalyzeLogs({ logs, context }: Props) {
|
|||||||
disabled={logs.length === 0}
|
disabled={logs.length === 0}
|
||||||
title="Analyze logs with AI"
|
title="Analyze logs with AI"
|
||||||
>
|
>
|
||||||
<Bot className="mr-2 h-4 w-4" />
|
<Bot className="mr-2 size-4" />
|
||||||
AI
|
AI
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -168,6 +187,18 @@ export function AnalyzeLogs({ logs, context }: Props) {
|
|||||||
)}
|
)}
|
||||||
Re-analyze
|
Re-analyze
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -347,11 +347,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||||
>
|
>
|
||||||
{isPaused ? (
|
{isPaused ? (
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<Pause className="mr-2 h-4 w-4" />
|
<Pause className="size-4" />
|
||||||
)}
|
)}
|
||||||
{isPaused ? "Resume" : "Pause"}
|
<span className="hidden lg:ml-2 lg:inline">
|
||||||
|
{isPaused ? "Resume" : "Pause"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -362,11 +364,13 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
title="Copy logs to clipboard"
|
title="Copy logs to clipboard"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="size-4" />
|
||||||
)}
|
)}
|
||||||
Copy
|
<span className="hidden lg:ml-2 lg:inline">
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -374,17 +378,18 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
className="h-9 sm:w-auto w-full"
|
className="h-9 sm:w-auto w-full"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||||
|
title="Download logs as text file"
|
||||||
>
|
>
|
||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
<DownloadIcon className="size-4" />
|
||||||
Download logs
|
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
|
||||||
</Button>
|
</Button>
|
||||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning" className="items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pause className="h-4 w-4" />
|
<Pause className="size-4" />
|
||||||
<span>
|
<span>
|
||||||
Logs paused
|
Logs paused
|
||||||
{messageBuffer.length > 0 && (
|
{messageBuffer.length > 0 && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import {
|
import {
|
||||||
uploadFileToContainerSchema,
|
|
||||||
type UploadFileToContainer,
|
type UploadFileToContainer,
|
||||||
|
uploadFileToContainerSchema,
|
||||||
} from "@/utils/schema";
|
} from "@/utils/schema";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mariadbId: string;
|
mariadbId: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mongoId: string;
|
mongoId: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mysqlId: string;
|
mysqlId: string;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ interface Props {
|
|||||||
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
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 [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
@@ -171,7 +174,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
Select a Server{" "}
|
||||||
|
{showLocalOption ? "(Optional)" : ""}
|
||||||
<HelpCircle className="size-4 text-muted-foreground" />
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -191,17 +195,19 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
placeholder={
|
||||||
|
showLocalOption ? "Dokploy" : "Select a Server"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{!isCloud && (
|
{showLocalOption && (
|
||||||
<SelectItem value="dokploy">
|
<SelectItem value="dokploy">
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
<span>Dokploy</span>
|
<span>Dokploy</span>
|
||||||
@@ -225,7 +231,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
Servers (
|
||||||
|
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
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 { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.compose.create.useMutation();
|
api.compose.create.useMutation();
|
||||||
@@ -182,7 +185,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
Select a Server {!isCloud ? "(Optional)" : ""}
|
Select a Server{" "}
|
||||||
|
{showLocalOption ? "(Optional)" : ""}
|
||||||
<HelpCircle className="size-4 text-muted-foreground" />
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -202,17 +206,19 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
field.value || (showLocalOption ? "dokploy" : undefined)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
placeholder={
|
||||||
|
showLocalOption ? "Dokploy" : "Select a Server"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{!isCloud && (
|
{showLocalOption && (
|
||||||
<SelectItem value="dokploy">
|
<SelectItem value="dokploy">
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
<span>Dokploy</span>
|
<span>Dokploy</span>
|
||||||
@@ -236,7 +242,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
Servers (
|
||||||
|
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
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 { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const libsqlMutation = api.libsql.create.useMutation();
|
const libsqlMutation = api.libsql.create.useMutation();
|
||||||
const mariadbMutation = api.mariadb.create.useMutation();
|
const mariadbMutation = api.mariadb.create.useMutation();
|
||||||
@@ -470,19 +473,20 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
field.value ||
|
||||||
|
(showLocalOption ? "dokploy" : undefined)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={
|
placeholder={
|
||||||
!isCloud ? "Dokploy" : "Select a Server"
|
showLocalOption ? "Dokploy" : "Select a Server"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{!isCloud && (
|
{showLocalOption && (
|
||||||
<SelectItem value="dokploy">
|
<SelectItem value="dokploy">
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
<span>Dokploy</span>
|
<span>Dokploy</span>
|
||||||
@@ -501,7 +505,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
Servers (
|
||||||
|
{servers?.length + (showLocalOption ? 1 : 0)})
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -632,7 +637,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="enableNamespaces"
|
name="enableNamespaces"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
console.log(field.value);
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Enable Namespaces</FormLabel>
|
<FormLabel>Enable Namespaces</FormLabel>
|
||||||
|
|||||||
494
apps/dokploy/components/dashboard/project/add-import.tsx
Normal file
494
apps/dokploy/components/dashboard/project/add-import.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BookText,
|
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
BookText,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Globe,
|
Globe,
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ export const ShowProjects = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border flex flex-col">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||||
<span className="flex flex-col gap-1.5 ">
|
<span className="flex flex-col gap-1.5 ">
|
||||||
@@ -491,7 +491,7 @@ export const ShowProjects = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="pt-4">
|
<CardFooter className="pt-4 mt-auto">
|
||||||
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-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}>
|
<DateTooltip date={project.createdAt}>
|
||||||
Created
|
Created
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { toast } from "sonner";
|
||||||
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
|
||||||
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
redisId: string;
|
redisId: string;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { NumberInput } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { NumberInput } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ export const ShowGitProviders = () => {
|
|||||||
api.gitProvider.remove.useMutation();
|
api.gitProvider.remove.useMutation();
|
||||||
const { mutateAsync: toggleShare, isPending: isToggling } =
|
const { mutateAsync: toggleShare, isPending: isToggling } =
|
||||||
api.gitProvider.toggleShare.useMutation();
|
api.gitProvider.toggleShare.useMutation();
|
||||||
|
const { data: currentMember } = api.user.get.useQuery();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const url = useUrl();
|
const url = useUrl();
|
||||||
|
const isOrgAdmin =
|
||||||
|
currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||||
|
|
||||||
const getGitlabUrl = (
|
const getGitlabUrl = (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
@@ -87,18 +91,20 @@ export const ShowGitProviders = () => {
|
|||||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GitBranch className="size-8 self-center text-muted-foreground" />
|
<GitBranch className="size-8 self-center text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground text-center">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
Create your first Git Provider
|
No Git Providers configured
|
||||||
</span>
|
</span>
|
||||||
<div>
|
{permissions?.gitProviders.create && (
|
||||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||||
<AddGithubProvider />
|
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||||
<AddGitlabProvider />
|
<AddGithubProvider />
|
||||||
<AddBitbucketProvider />
|
<AddGitlabProvider />
|
||||||
<AddGiteaProvider />
|
<AddBitbucketProvider />
|
||||||
|
<AddGiteaProvider />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
@@ -106,14 +112,16 @@ export const ShowGitProviders = () => {
|
|||||||
<span className="text-base font-medium">
|
<span className="text-base font-medium">
|
||||||
Available Providers
|
Available Providers
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
{permissions?.gitProviders.create && (
|
||||||
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
|
||||||
<AddGithubProvider />
|
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
|
||||||
<AddGitlabProvider />
|
<AddGithubProvider />
|
||||||
<AddBitbucketProvider />
|
<AddGitlabProvider />
|
||||||
<AddGiteaProvider />
|
<AddBitbucketProvider />
|
||||||
|
<AddGiteaProvider />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-lg ">
|
<div className="flex flex-col gap-4 rounded-lg ">
|
||||||
@@ -123,17 +131,13 @@ export const ShowGitProviders = () => {
|
|||||||
const isBitbucket =
|
const isBitbucket =
|
||||||
gitProvider.providerType === "bitbucket";
|
gitProvider.providerType === "bitbucket";
|
||||||
const isGitea = gitProvider.providerType === "gitea";
|
const isGitea = gitProvider.providerType === "gitea";
|
||||||
|
const canManage = gitProvider.isOwner || isOrgAdmin;
|
||||||
|
|
||||||
const haveGithubRequirements =
|
const haveGithubRequirements =
|
||||||
isGithub &&
|
isGithub && gitProvider.github?.isConfigured;
|
||||||
gitProvider.github?.githubPrivateKey &&
|
|
||||||
gitProvider.github?.githubAppId &&
|
|
||||||
gitProvider.github?.githubInstallationId;
|
|
||||||
|
|
||||||
const haveGitlabRequirements =
|
const haveGitlabRequirements =
|
||||||
isGitlab &&
|
isGitlab && gitProvider.gitlab?.isConfigured;
|
||||||
gitProvider.gitlab?.accessToken &&
|
|
||||||
gitProvider.gitlab?.refreshToken;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -221,8 +225,7 @@ export const ShowGitProviders = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isBitbucket &&
|
{isBitbucket &&
|
||||||
gitProvider.bitbucket?.appPassword &&
|
gitProvider.bitbucket?.isDeprecated ? (
|
||||||
!gitProvider.bitbucket?.apiToken ? (
|
|
||||||
<Badge variant="yellow">Deprecated</Badge>
|
<Badge variant="yellow">Deprecated</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -235,7 +238,7 @@ export const ShowGitProviders = () => {
|
|||||||
Action Required
|
Action Required
|
||||||
</Badge>
|
</Badge>
|
||||||
<Link
|
<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({
|
className={buttonVariants({
|
||||||
size: "icon",
|
size: "icon",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
@@ -271,7 +274,7 @@ export const ShowGitProviders = () => {
|
|||||||
href={getGitlabUrl(
|
href={getGitlabUrl(
|
||||||
gitProvider.gitlab?.applicationId || "",
|
gitProvider.gitlab?.applicationId || "",
|
||||||
gitProvider.gitlab?.gitlabId || "",
|
gitProvider.gitlab?.gitlabId || "",
|
||||||
gitProvider.gitlab?.gitlabUrl,
|
gitProvider.gitlab?.gitlabUrl || "",
|
||||||
)}
|
)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
@@ -284,31 +287,35 @@ export const ShowGitProviders = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gitProvider.isOwner && (
|
{canManage && (
|
||||||
<>
|
<>
|
||||||
{isGithub && haveGithubRequirements && (
|
{isGithub &&
|
||||||
<EditGithubProvider
|
haveGithubRequirements &&
|
||||||
githubId={gitProvider.github?.githubId}
|
gitProvider.github?.githubId && (
|
||||||
/>
|
<EditGithubProvider
|
||||||
)}
|
githubId={gitProvider.github.githubId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGitlab && (
|
{isGitlab &&
|
||||||
<EditGitlabProvider
|
gitProvider.gitlab?.gitlabId && (
|
||||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
<EditGitlabProvider
|
||||||
/>
|
gitlabId={gitProvider.gitlab.gitlabId}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isBitbucket && (
|
{isBitbucket &&
|
||||||
<EditBitbucketProvider
|
gitProvider.bitbucket?.bitbucketId && (
|
||||||
bitbucketId={
|
<EditBitbucketProvider
|
||||||
gitProvider.bitbucket?.bitbucketId
|
bitbucketId={
|
||||||
}
|
gitProvider.bitbucket.bitbucketId
|
||||||
/>
|
}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGitea && (
|
{isGitea && gitProvider.gitea?.giteaId && (
|
||||||
<EditGiteaProvider
|
<EditGiteaProvider
|
||||||
giteaId={gitProvider.gitea?.giteaId}
|
giteaId={gitProvider.gitea.giteaId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
@@ -7,8 +9,6 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { HelpCircle } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const ToggleRemoteServersOnly = () => {
|
||||||
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync } = api.settings.updateRemoteServersOnly.useMutation();
|
||||||
|
|
||||||
|
const handleToggle = async (checked: boolean) => {
|
||||||
|
try {
|
||||||
|
await mutateAsync({ remoteServersOnly: checked });
|
||||||
|
await refetch();
|
||||||
|
toast.success("Remote Servers Only updated");
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating Remote Servers Only");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Switch
|
||||||
|
checked={!!data?.remoteServersOnly}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||||
|
Remote Servers Only
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</Label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-sm">
|
||||||
|
<p>
|
||||||
|
When enabled, all services (applications, databases, compose) must
|
||||||
|
be deployed to a remote server. Deploying directly to the Dokploy
|
||||||
|
host VM is not allowed.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ const Schema = z.object({
|
|||||||
message: "SSH Key is required",
|
message: "SSH Key is required",
|
||||||
}),
|
}),
|
||||||
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
||||||
|
enableDockerCleanup: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
@@ -90,6 +92,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
username: "root",
|
username: "root",
|
||||||
sshKeyId: "",
|
sshKeyId: "",
|
||||||
serverType: "deploy",
|
serverType: "deploy",
|
||||||
|
enableDockerCleanup: true,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
@@ -103,6 +106,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
username: data?.username || "root",
|
username: data?.username || "root",
|
||||||
sshKeyId: data?.sshKeyId || "",
|
sshKeyId: data?.sshKeyId || "",
|
||||||
serverType: data?.serverType || "deploy",
|
serverType: data?.serverType || "deploy",
|
||||||
|
enableDockerCleanup: data?.enableDockerCleanup ?? true,
|
||||||
});
|
});
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
@@ -119,6 +123,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
username: data.username || "root",
|
username: data.username || "root",
|
||||||
sshKeyId: data.sshKeyId || "",
|
sshKeyId: data.sshKeyId || "",
|
||||||
serverType: data.serverType || "deploy",
|
serverType: data.serverType || "deploy",
|
||||||
|
enableDockerCleanup: data.enableDockerCleanup,
|
||||||
serverId: serverId || "",
|
serverId: serverId || "",
|
||||||
})
|
})
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
@@ -418,6 +423,27 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
</FormItem>
|
</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>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -131,10 +131,10 @@ export const ShowServers = () => {
|
|||||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<ServerIcon className="size-5 text-muted-foreground" />
|
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<CardTitle className="text-lg">
|
<CardTitle className="text-lg break-words min-w-0">
|
||||||
{server.name}
|
{server.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +145,7 @@ export const ShowServers = () => {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 shrink-0 p-0"
|
||||||
>
|
>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
More options
|
More options
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
|
|
||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
|
|
||||||
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
|
||||||
|
|||||||
@@ -141,14 +141,14 @@ export const WebDomain = () => {
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-4 md:grid-cols-2"
|
className="grid w-full gap-4 grid-cols-2"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="domain"
|
name="domain"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem className="col-span-2 md:col-span-1">
|
||||||
<FormLabel>Domain</FormLabel>
|
<FormLabel>Domain</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -168,7 +168,7 @@ export const WebDomain = () => {
|
|||||||
name="letsEncryptEmail"
|
name="letsEncryptEmail"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem className="col-span-2 md:col-span-1">
|
||||||
<FormLabel>Let's Encrypt Email</FormLabel>
|
<FormLabel>Let's Encrypt Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -209,7 +209,7 @@ export const WebDomain = () => {
|
|||||||
name="certificateType"
|
name="certificateType"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="col-span-2">
|
||||||
<FormLabel>Certificate Provider</FormLabel>
|
<FormLabel>Certificate Provider</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ServerIcon } from "lucide-react";
|
import copy from "copy-to-clipboard";
|
||||||
|
import { CopyIcon, ServerIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -49,8 +51,17 @@ export const WebServer = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
|
||||||
Server IP: {webServerSettings?.serverIp}
|
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>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version: {dokployVersion}
|
Version: {dokployVersion}
|
||||||
|
|||||||
@@ -868,6 +868,19 @@ 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) {
|
export default function Page({ children }: Props) {
|
||||||
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
|
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -933,6 +946,7 @@ export default function Page({ children }: Props) {
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<MobileCloser />
|
||||||
<Sidebar collapsible="icon" variant="floating">
|
<Sidebar collapsible="icon" variant="floating">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
{/* <SidebarMenuButton
|
{/* <SidebarMenuButton
|
||||||
|
|||||||
482
apps/dokploy/components/proprietary/sso/forward-auth-servers.tsx
Normal file
482
apps/dokploy/components/proprietary/sso/forward-auth-servers.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Dices,
|
||||||
|
HelpCircle,
|
||||||
|
Loader2,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DnsHelperModal } from "@/components/dashboard/application/domains/dns-helper-modal";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type ServerStatus = "running" | "stopped" | "unknown";
|
||||||
|
type Target = { serverId: string | null; name: string };
|
||||||
|
type CertType = "none" | "letsencrypt" | "custom";
|
||||||
|
type DomainForm = {
|
||||||
|
host: string;
|
||||||
|
https: boolean;
|
||||||
|
certificateType: CertType;
|
||||||
|
customCertResolver: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ForwardAuthServers = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [deployTarget, setDeployTarget] = useState<Target | null>(null);
|
||||||
|
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||||
|
const [forms, setForms] = useState<Record<string, DomainForm>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setEnabled(true), 0);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: hostIp } = api.settings.getIp.useQuery();
|
||||||
|
const { data: servers, isPending } = api.forwardAuth.serverStatus.useQuery(
|
||||||
|
undefined,
|
||||||
|
{ enabled, refetchOnWindowFocus: false, staleTime: 30_000 },
|
||||||
|
);
|
||||||
|
const { data: providers } = api.forwardAuth.listProviders.useQuery(
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
enabled: !!deployTarget,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: saveAuthDomain, isPending: isSaving } =
|
||||||
|
api.forwardAuth.setAuthDomain.useMutation();
|
||||||
|
const { mutateAsync: deployOnServer, isPending: isDeploying } =
|
||||||
|
api.forwardAuth.deployOnServer.useMutation();
|
||||||
|
const { mutateAsync: removeOnServer, isPending: isRemoving } =
|
||||||
|
api.forwardAuth.removeOnServer.useMutation();
|
||||||
|
const { mutateAsync: generateDomain, isPending: isGenerating } =
|
||||||
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
|
const keyOf = (serverId: string | null) => serverId ?? "local";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!servers) return;
|
||||||
|
setForms((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const srv of servers) {
|
||||||
|
const key = srv.serverId ?? "local";
|
||||||
|
if (next[key] === undefined) {
|
||||||
|
next[key] = {
|
||||||
|
host: srv.authDomain ?? "",
|
||||||
|
https: srv.https ?? true,
|
||||||
|
certificateType: (srv.certificateType ?? "letsencrypt") as CertType,
|
||||||
|
customCertResolver: srv.customCertResolver ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [servers]);
|
||||||
|
|
||||||
|
const hasProviders = (providers?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const patchForm = (serverId: string | null, patch: Partial<DomainForm>) =>
|
||||||
|
setForms((p) => {
|
||||||
|
const key = keyOf(serverId);
|
||||||
|
const current: DomainForm = p[key] ?? {
|
||||||
|
host: "",
|
||||||
|
https: true,
|
||||||
|
certificateType: "letsencrypt",
|
||||||
|
customCertResolver: "",
|
||||||
|
};
|
||||||
|
return { ...p, [key]: { ...current, ...patch } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveDomain = async (serverId: string | null) => {
|
||||||
|
const f = forms[keyOf(serverId)];
|
||||||
|
if (!f?.host.trim()) {
|
||||||
|
toast.error("Enter an auth domain first");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (f.certificateType === "custom" && !f.customCertResolver.trim()) {
|
||||||
|
toast.error("Enter the custom certificate resolver");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await saveAuthDomain({
|
||||||
|
serverId,
|
||||||
|
authDomain: f.host.trim(),
|
||||||
|
https: f.https,
|
||||||
|
certificateType: f.certificateType,
|
||||||
|
customCertResolver: f.customCertResolver.trim() || undefined,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error saving auth domain",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeploy = async () => {
|
||||||
|
if (!deployTarget || !selectedProviderId) {
|
||||||
|
toast.error("Select an SSO provider first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = await handleSaveDomain(deployTarget.serverId);
|
||||||
|
if (!saved) return;
|
||||||
|
await deployOnServer({
|
||||||
|
serverId: deployTarget.serverId,
|
||||||
|
providerId: selectedProviderId,
|
||||||
|
});
|
||||||
|
await utils.forwardAuth.serverStatus.invalidate();
|
||||||
|
toast.success("Authentication proxy deployed");
|
||||||
|
setDeployTarget(null);
|
||||||
|
setSelectedProviderId("");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error deploying proxy",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (serverId: string | null) => {
|
||||||
|
try {
|
||||||
|
await removeOnServer({ serverId });
|
||||||
|
await utils.forwardAuth.serverStatus.invalidate();
|
||||||
|
toast.success("Authentication proxy removed");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error removing proxy",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateDomain = async (serverId: string | null) => {
|
||||||
|
try {
|
||||||
|
const host = await generateDomain({
|
||||||
|
appName: "auth",
|
||||||
|
serverId: serverId ?? undefined,
|
||||||
|
});
|
||||||
|
patchForm(serverId, { host, https: false, certificateType: "none" });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error generating domain",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (status: ServerStatus) => {
|
||||||
|
if (status === "running") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-emerald-500/40 text-emerald-500"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="mr-1 size-3" />
|
||||||
|
Running
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "stopped") {
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<ShieldOff className="mr-1 size-3" />
|
||||||
|
Not deployed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-500/40 text-amber-500"
|
||||||
|
title="Could not reach this server in time"
|
||||||
|
>
|
||||||
|
<HelpCircle className="mr-1 size-3" />
|
||||||
|
Unknown
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-xl">
|
||||||
|
<ShieldCheck className="size-5" />
|
||||||
|
Application Authentication
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Each server has its own authentication domain and proxy. Set an auth
|
||||||
|
domain (e.g. auth.acme.com) per server, register its callback URL once
|
||||||
|
in your identity provider, then deploy the proxy. Apps on that server
|
||||||
|
under the same base domain are then one click to protect.
|
||||||
|
<span className="mt-2 block font-medium">
|
||||||
|
Only OIDC providers are supported — SAML is not compatible with the
|
||||||
|
forward-auth proxy.
|
||||||
|
</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isPending || !enabled ? (
|
||||||
|
<div className="flex items-center gap-2 justify-center py-6 text-muted-foreground">
|
||||||
|
<Loader2 className="size-5 animate-spin" />
|
||||||
|
<span className="text-sm">Checking servers...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{servers?.map((srv) => {
|
||||||
|
const key = keyOf(srv.serverId);
|
||||||
|
const f = forms[key];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{srv.name}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusBadge(srv.status)}
|
||||||
|
{srv.status === "running" && (
|
||||||
|
<DialogAction
|
||||||
|
title="Remove authentication proxy"
|
||||||
|
description="Domains on this server protected with SSO will stop requiring authentication until re-enabled. Continue?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={() => handleRemove(srv.serverId)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium">Auth domain</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="auth.acme.com"
|
||||||
|
value={f?.host ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchForm(srv.serverId, { host: e.target.value })
|
||||||
|
}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{f?.host && !f.host.includes("sslip.io") && (
|
||||||
|
<DnsHelperModal
|
||||||
|
domain={{
|
||||||
|
host: f.host,
|
||||||
|
https: f.https,
|
||||||
|
}}
|
||||||
|
serverIp={
|
||||||
|
srv.ipAddress ?? hostIp?.toString() ?? undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
isLoading={isGenerating}
|
||||||
|
title="Generate sslip.io domain"
|
||||||
|
onClick={() => handleGenerateDomain(srv.serverId)}
|
||||||
|
>
|
||||||
|
<Dices className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Certificate provider
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={f?.https ? f.certificateType : "none"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
patchForm(srv.serverId, {
|
||||||
|
certificateType: v as CertType,
|
||||||
|
https: v !== "none",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a certificate provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None (HTTP)</SelectItem>
|
||||||
|
<SelectItem value="letsencrypt">
|
||||||
|
Let's Encrypt
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{f?.certificateType === "custom" && f?.https && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Custom certificate resolver
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your custom certificate resolver"
|
||||||
|
value={f?.customCertResolver ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchForm(srv.serverId, {
|
||||||
|
customCertResolver: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!f?.host?.trim()}
|
||||||
|
onClick={() =>
|
||||||
|
setDeployTarget({
|
||||||
|
serverId: srv.serverId,
|
||||||
|
name: srv.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{srv.callbackUrl && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
Callback URL (register once in your IdP)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={srv.callbackUrl}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
srv.callbackUrl as string,
|
||||||
|
);
|
||||||
|
toast.success("Callback URL copied");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={!!deployTarget}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDeployTarget(null);
|
||||||
|
setSelectedProviderId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Deploy authentication proxy</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Deploy the SSO proxy on{" "}
|
||||||
|
<span className="font-medium">{deployTarget?.name}</span> using an
|
||||||
|
OIDC provider.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!hasProviders && (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
No SSO providers configured. Add an OIDC provider above first.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 py-2">
|
||||||
|
<span className="text-sm font-medium">Identity provider</span>
|
||||||
|
<Select
|
||||||
|
value={selectedProviderId}
|
||||||
|
onValueChange={setSelectedProviderId}
|
||||||
|
disabled={!hasProviders}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an SSO provider">
|
||||||
|
{selectedProviderId || ""}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providers?.map((provider) => (
|
||||||
|
<SelectItem
|
||||||
|
key={provider.providerId}
|
||||||
|
value={provider.providerId}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{provider.providerId}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{provider.issuer}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isSaving || isDeploying}
|
||||||
|
disabled={!hasProviders || !selectedProviderId}
|
||||||
|
onClick={handleDeploy}
|
||||||
|
>
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,10 +29,15 @@ type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
|
|||||||
|
|
||||||
interface SignInWithSSOProps {
|
interface SignInWithSSOProps {
|
||||||
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
||||||
children: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
/** When true, SSO is the only option — no fallback to email/password */
|
||||||
|
enforce?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
export function SignInWithSSO({
|
||||||
|
children,
|
||||||
|
enforce = false,
|
||||||
|
}: SignInWithSSOProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const form = useForm<SSOEmailForm>({
|
const form = useForm<SSOEmailForm>({
|
||||||
@@ -72,7 +77,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
|||||||
<LogIn className="mr-2 size-4" />
|
<LogIn className="mr-2 size-4" />
|
||||||
Sign in with SSO
|
Sign in with SSO
|
||||||
</Button>
|
</Button>
|
||||||
{children}
|
{!enforce && children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,13 +118,15 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
{!enforce && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setExpanded(false)}
|
type="button"
|
||||||
className="text-xs text-muted-foreground hover:underline"
|
onClick={() => setExpanded(false)}
|
||||||
>
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
Use email and password instead
|
>
|
||||||
</button>
|
Use email and password instead
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
<FolderInput className="size-4 text-muted-foreground" />
|
<FolderInput className="size-4 text-muted-foreground" />
|
||||||
<span className="font-medium max-w-[150px] truncate">
|
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentProject?.name || "Select Project"}
|
{currentProject?.name || "Select Project"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
@@ -478,7 +478,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
aria-expanded={environmentOpen}
|
aria-expanded={environmentOpen}
|
||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
<span className="font-medium max-w-[150px] truncate">
|
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentEnvironment?.name || "production"}
|
{currentEnvironment?.name || "production"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
@@ -533,7 +533,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{projectEnvironments && projectEnvironments.length === 1 && (
|
{projectEnvironments && projectEnvironments.length === 1 && (
|
||||||
<p className="text-sm font-normal ml-1">
|
<p className="text-sm font-normal ml-1 max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentEnvironment?.name || "production"}
|
{currentEnvironment?.name || "production"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -551,7 +551,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
{getServiceIcon(currentService.type)}
|
{getServiceIcon(currentService.type)}
|
||||||
<span className="font-medium max-w-[150px] truncate">
|
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||||
{currentService.name}
|
{currentService.name}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
@@ -617,7 +617,7 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 ml-1"
|
className="size-7 ml-1 hidden md:flex"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${projectId}/environment/${environmentId}`,
|
`/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
|
|||||||
@@ -167,7 +167,13 @@ export const CodeEditor = ({
|
|||||||
? css()
|
? css()
|
||||||
: language === "shell"
|
: language === "shell"
|
||||||
? StreamLanguage.define(shell)
|
? StreamLanguage.define(shell)
|
||||||
: StreamLanguage.define(properties),
|
: 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: "#" } },
|
||||||
|
}),
|
||||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||||
language === "yaml"
|
language === "yaml"
|
||||||
? autocompletion({
|
? autocompletion({
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant, size, className }),
|
buttonVariants({ variant, size, className }),
|
||||||
"flex gap-2",
|
"flex gap-2",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
1
apps/dokploy/drizzle/0166_nosy_slapstick.sql
Normal file
1
apps/dokploy/drizzle/0166_nosy_slapstick.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "schedule" ADD COLUMN "description" text;
|
||||||
1
apps/dokploy/drizzle/0167_fresh_goliath.sql
Normal file
1
apps/dokploy/drizzle/0167_fresh_goliath.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;
|
||||||
1
apps/dokploy/drizzle/0168_long_justice.sql
Normal file
1
apps/dokploy/drizzle/0168_long_justice.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;
|
||||||
11
apps/dokploy/drizzle/0169_parched_johnny_storm.sql
Normal file
11
apps/dokploy/drizzle/0169_parched_johnny_storm.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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";
|
||||||
16
apps/dokploy/drizzle/0170_amusing_spot.sql
Normal file
16
apps/dokploy/drizzle/0170_amusing_spot.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
3
apps/dokploy/drizzle/0171_lucky_echo.sql
Normal file
3
apps/dokploy/drizzle/0171_lucky_echo.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
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;
|
||||||
8318
apps/dokploy/drizzle/meta/0166_snapshot.json
Normal file
8318
apps/dokploy/drizzle/meta/0166_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8325
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
8325
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8332
apps/dokploy/drizzle/meta/0168_snapshot.json
Normal file
8332
apps/dokploy/drizzle/meta/0168_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8332
apps/dokploy/drizzle/meta/0169_snapshot.json
Normal file
8332
apps/dokploy/drizzle/meta/0169_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8444
apps/dokploy/drizzle/meta/0170_snapshot.json
Normal file
8444
apps/dokploy/drizzle/meta/0170_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8444
apps/dokploy/drizzle/meta/0171_snapshot.json
Normal file
8444
apps/dokploy/drizzle/meta/0171_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1163,6 +1163,48 @@
|
|||||||
"when": 1775845419261,
|
"when": 1775845419261,
|
||||||
"tag": "0165_abnormal_greymalkin",
|
"tag": "0165_abnormal_greymalkin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ try {
|
|||||||
"wait-for-postgres": "wait-for-postgres.ts",
|
"wait-for-postgres": "wait-for-postgres.ts",
|
||||||
"reset-password": "reset-password.ts",
|
"reset-password": "reset-password.ts",
|
||||||
"reset-2fa": "reset-2fa.ts",
|
"reset-2fa": "reset-2fa.ts",
|
||||||
|
"migrate-auth-secret": "scripts/migrate-auth-secret.ts",
|
||||||
},
|
},
|
||||||
bundle: true,
|
bundle: true,
|
||||||
platform: "node",
|
platform: "node",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.29.2",
|
"version": "v0.29.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
||||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.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 ",
|
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||||
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||||
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
||||||
@@ -122,11 +123,11 @@
|
|||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"next": "^16.2.0",
|
"next": "16.2.6",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
"node-os-utils": "2.0.1",
|
"node-os-utils": "2.0.1",
|
||||||
"node-pty": "1.0.0",
|
"node-pty": "1.1.0",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export default async function handler(
|
|||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
) {
|
) {
|
||||||
const signature = req.headers["x-hub-signature-256"];
|
const signature = req.headers["x-hub-signature-256"];
|
||||||
|
if (!signature) {
|
||||||
|
res.status(401).json({ message: "Missing signature header" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const githubBody = req.body;
|
const githubBody = req.body;
|
||||||
|
|
||||||
if (!githubBody?.installation?.id) {
|
if (!githubBody?.installation?.id) {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export async function getServerSideProps(
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/dashboard/home",
|
destination: "/dashboard/home",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -104,7 +104,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant"
|
|||||||
import { AddApplication } from "@/components/dashboard/project/add-application";
|
import { AddApplication } from "@/components/dashboard/project/add-application";
|
||||||
import { AddCompose } from "@/components/dashboard/project/add-compose";
|
import { AddCompose } from "@/components/dashboard/project/add-compose";
|
||||||
import { AddDatabase } from "@/components/dashboard/project/add-database";
|
import { AddDatabase } from "@/components/dashboard/project/add-database";
|
||||||
|
import { AddImport } from "@/components/dashboard/project/add-import";
|
||||||
import { AddTemplate } from "@/components/dashboard/project/add-template";
|
import { AddTemplate } from "@/components/dashboard/project/add-template";
|
||||||
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
|
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
|
||||||
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
|
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
|
||||||
@@ -1091,6 +1092,10 @@ const EnvironmentPage = (
|
|||||||
projectName={projectData?.name}
|
projectName={projectData?.name}
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
/>
|
/>
|
||||||
|
<AddImport
|
||||||
|
projectName={projectData?.name}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
@@ -1099,7 +1104,7 @@ const EnvironmentPage = (
|
|||||||
</div>
|
</div>
|
||||||
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
|
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-center 2xl:justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1620,9 +1625,9 @@ const EnvironmentPage = (
|
|||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||||
className="block"
|
className="block h-full"
|
||||||
>
|
>
|
||||||
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
<Card className="flex flex-col h-full group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||||
{service.serverId && (
|
{service.serverId && (
|
||||||
<div className="absolute -left-1 -top-2">
|
<div className="absolute -left-1 -top-2">
|
||||||
<ServerIcon className="size-4 text-muted-foreground" />
|
<ServerIcon className="size-4 text-muted-foreground" />
|
||||||
@@ -1827,7 +1832,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const Service = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
@@ -147,8 +148,9 @@ const Service = (
|
|||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (data?.server?.ipAddress) {
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
copy(data.server.ipAddress);
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
toast.success("IP Address Copied!");
|
toast.success("IP Address Copied!");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -451,7 +453,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const Service = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -134,8 +135,9 @@ const Service = (
|
|||||||
<Badge
|
<Badge
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (data?.server?.ipAddress) {
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
copy(data.server.ipAddress);
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
toast.success("IP Address Copied!");
|
toast.success("IP Address Copied!");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -455,7 +457,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
@@ -9,6 +10,7 @@ import Head from "next/head";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
@@ -61,6 +63,7 @@ const Libsql = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -99,6 +102,14 @@ const Libsql = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
@@ -307,7 +318,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
@@ -9,6 +10,7 @@ import Head from "next/head";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
@@ -63,6 +65,7 @@ const Mariadb = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
@@ -111,6 +114,14 @@ const Mariadb = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
@@ -336,7 +347,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
@@ -9,6 +10,7 @@ import Head from "next/head";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
@@ -63,6 +65,7 @@ const Mongo = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -110,6 +113,14 @@ const Mongo = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
@@ -340,7 +351,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { HelpCircle, ServerOff } from "lucide-react";
|
import { HelpCircle, ServerOff } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
@@ -9,6 +10,7 @@ import Head from "next/head";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { type ReactElement, useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
|
||||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||||
@@ -62,6 +64,7 @@ const MySql = (
|
|||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: serverIp } = api.settings.getIp.useQuery();
|
||||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
projectId: data?.environment?.projectId || "",
|
projectId: data?.environment?.projectId || "",
|
||||||
});
|
});
|
||||||
@@ -110,6 +113,14 @@ const MySql = (
|
|||||||
<div className="flex flex-col h-fit w-fit gap-2">
|
<div className="flex flex-col h-fit w-fit gap-2">
|
||||||
<div className="flex flex-row h-fit w-fit gap-2">
|
<div className="flex flex-row h-fit w-fit gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const ip = data?.server?.ipAddress || serverIp;
|
||||||
|
if (ip) {
|
||||||
|
copy(ip);
|
||||||
|
toast.success("IP Address Copied!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant={
|
variant={
|
||||||
!data?.serverId
|
!data?.serverId
|
||||||
? "default"
|
? "default"
|
||||||
@@ -318,7 +329,7 @@ export async function getServerSideProps(
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: false,
|
||||||
destination: "/",
|
destination: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user