Compare commits

..

137 Commits

Author SHA1 Message Date
github-actions[bot] b3c2e1e5af 🚀 Release v0.29.8 (#4562)
* fix(migrate-auth-secret): exit cleanly when there are no 2FA records

The empty-records branch of `main()` returned without calling
`process.exit(0)`, leaving the Drizzle Postgres connection pool
holding the event loop open. The `migrate-auth-secret` process
then hangs indefinitely after printing "No 2FA records found,
nothing to migrate." causing the upstream `0.29.3.sh` security
migration script (which calls this via `docker exec`) to never
reach its final `docker service update` step that mounts the new
Docker Secret. Operators end up with the new secret created but
the dokploy service still configured with the hardcoded
`BETTER_AUTH_SECRET`, while believing the migration completed.

Match the success branch a few lines below which already does
`process.exit(0)`, and the pattern used in sibling scripts
`reset-password.ts` and `reset-2fa.ts`.

Closes #4392

* feat(compose): add import from base64 in create service dropdown

Adds an "Import" option to the Create Service dropdown that lets users
paste a base64-encoded compose export, preview the template (compose YAML,
domains, envs, mounts) before confirming, and create the service only on
confirm. Adds a `previewTemplate` tRPC procedure that processes the base64
without touching the DB, with server access validation via session.

* [autofix.ci] apply automated fixes

* Enhance version synchronization workflow to include SDK repository

- Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories.
- Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec.
- Improved commit message formatting to include source and release information for all repositories.
- Ensured successful synchronization messages for each repository after the version update.

* feat(deployment): add readLogs procedure to fetch deployment logs

- Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter.
- Implemented permission checks to ensure users have access to the requested logs.
- Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context.

Resolve https://github.com/Dokploy/mcp/issues/14

* feat(deployment): add server access validation for deployment actions

- Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization.
- Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization.

This enhancement improves security and access control within the deployment management system.

* feat(organization): prevent inviting users with owner role

- Added validation to prevent users from being invited with the owner role in the organization and user routers.
- Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role.
This change enhances role management and security within the organization structure.

https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh

* feat(user): implement session cleanup on user update

- Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active.
- This change enhances security by preventing unauthorized access from previous sessions after a password change.

Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3

* feat(settings): add copy button to server IP in web server settings (#4397)

* fix: copy Dokploy server IP when clicking server badge (#4390)

* fix: copy Dokploy server IP when clicking server badge

When a service runs on the local Dokploy server (no remote server),
clicking the server badge did nothing because `data.server` is null.
Now falls back to the server IP from settings so the badge always
copies an IP address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(copy-ip): implement IP address copying functionality across database service components

- Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis).
- Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action.
- Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>

* fix: responsive layout (#4391)

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>

* fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382)

* fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468)

* fix: allow square brackets in zip drop path validation for Next.js dynamic routes

ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts,
pages/[slug].tsx) were rejected by readValidDirectory because the path regex
did not include square bracket characters.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: prevent webhook deploy crash when commit data lacks modified files (#4470)

shouldDeploy passed undefined/null entries from commit.modified straight
into micromatch, which throws "Expected input to be a string" and fails
every webhook deployment when watch paths are configured. Filter out
non-string values before matching.

* fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)

Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>

* fix: enable comment toggle shortcut in env variable editor (#4402) (#4473)

* fix: add tls=true label for domains when certificateType is none (#4018) (#4474)

* fix: add tls=true label for compose domains when certificateType is none (#4018)

* test: cover tls=true label for certificateType none, require https

* fix: scope tls fix to compose labels, leave traefik file config unchanged (#4018)

* chore: update version to v0.29.5 in package.json

* chore(deps): upgrade next to 16.2.6 (#4477)

Upgraded next dependency in apps/dokploy to 16.2.6 exactly. Verified typescript typecheck passes successfully.

* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso) (#4511)

* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso)

- Add `remoteServersOnly` field to webServerSettings: prevents creating services
  on the local Dokploy VM, forcing all deployments to remote servers. Validated
  in all 8 service routers (application, compose, postgres, mysql, mongo, redis,
  mariadb, libsql).
- Add `enforceSSO` field to webServerSettings: hides the email/password login
  form and shows only the SSO button on the login page.
- Both settings are enterprise-only (enterpriseProcedure) and self-hosted-only
  (blocked at the API level when IS_CLOUD=true).
- UI toggles added to the SSO settings page under a new "Self-hosted
  Restrictions" card (hidden in cloud). Login page reads enforceSSO from
  getServerSideProps to avoid client-side flash.
- Migrations: 0167_fresh_goliath.sql, 0168_long_justice.sql

* fix: add missing final newlines to migration files

* refactor: improve code formatting for better readability in multiple components

- Adjusted formatting in `add-application.tsx`, `add-compose.tsx`, and `add-database.tsx` to enhance readability by adding line breaks and consistent indentation.
- Updated `toggle-enforce-sso.tsx` to simplify the Switch component's props.
- Reformatted imports in `index.tsx` and `sso.tsx` for consistency.
- Cleaned up conditional statements in various router files for improved clarity.

* fix: add enforceSSO to test mock

* fix: grant create and delete SSH key permissions when canAccessToSSHKeys is enabled for members (#4512)

* fix: use create permission for basic auth delete instead of delete (#4513)

* fix: wrap long server names and keep actions menu visible (#4434)

On settings/servers, a long server name in the card title (h3) did not
wrap and overflowed its container, overlapping nearby content and
squeezing the three-dots actions menu until it disappeared.

Allow the title block to shrink and wrap (min-w-0 + break-words), keep
the server icon and the actions trigger from being crushed (shrink-0),
and add gap between the title and the actions button.

* chore: update version to v0.29.6 in package.json

* fix: preserve HOME in compose deploy so --with-registry-auth can read docker config (#4485)

The compose/stack deploy command runs under `env -i PATH="$PATH"`, which
clears the environment except for PATH. That strips HOME, so when the
generated command is `docker stack deploy --prune --with-registry-auth`
the docker CLI cannot resolve `~/.docker/config.json` (e.g.
`/root/.docker/config.json`) and ships no registry credentials to the
swarm. Private-registry images then fail to pull on the nodes:

  image registry.example.com/... could not be accessed on a registry to
  record its digest. Each node will access ... independently

while the deploy still logs "Docker Compose Deployed: ".

Keep PATH isolation but preserve HOME so docker can read its config for
both `stack deploy --with-registry-auth` and `compose up -d --build`.

Add a regression test asserting the generated command preserves
`HOME="$HOME"` for both stack and docker-compose deploys.

Fixes #4401

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scope dokploy-server schedules to organization instead of user (#4526)

* fix: scope dokploy-server schedules to organization instead of user

Replaces userId with organizationId on the schedule table so that
global (dokploy-server) schedules are shared across all owners and
admins of the same organization, while remaining isolated between
different organizations.

Includes a data migration that backfills organizationId from the
owner membership record for any existing dokploy-server schedules.

Closes #4300

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* feat: implement forward authentication settings and UI components

- Added a new `forward_auth_settings` table to manage authentication domains and their configurations.
- Introduced UI components for handling forward authentication, including enabling/disabling SSO for domains and selecting SSO providers.
- Updated existing tests to include validation for the new `forwardAuthProviderId` field in domain configurations.
- Enhanced the dashboard to integrate forward authentication management, allowing users to configure SSO settings directly from the application interface.

This update improves the flexibility and security of application authentication by allowing integration with various identity providers.

* refactor: simplify forward authentication handling in UI and API

- Removed the selection of SSO providers from the UI, streamlining the process to enable/disable SSO for domains.
- Updated the API to eliminate the need for a provider ID when enabling forward authentication, relying on the configured settings instead.
- Enhanced user feedback by updating toast messages to reflect the current state of SSO authentication.
- Improved the UI layout for better clarity on SSO status and actions.

This refactor enhances the user experience by simplifying the SSO configuration process and ensuring clearer communication of actions taken.

* refactor: unify branch validation imports across provider components

- Added the `VALID_BRANCH_REGEX` import to all Git provider components to ensure consistent branch validation.
- Removed duplicate imports of `VALID_BRANCH_REGEX` to streamline the code and improve readability.

This change enhances maintainability by centralizing branch validation logic across the application.

* refactor: remove obsolete SQL migration files and snapshots

- Deleted several SQL migration files related to the `webServerSettings` and `schedule` tables, which included adding and dropping columns and constraints.
- Removed snapshots corresponding to the deleted migrations to maintain consistency in the database schema history.

This cleanup enhances the maintainability of the migration history by removing outdated and unused files.

* refactor: update forward authentication handling in domain schema and tests

- Replaced `forwardAuthProviderId` with `forwardAuthEnabled` in the domain schema to simplify the configuration of forward authentication.
- Updated related tests to reflect this change, ensuring consistency across the application.
- Introduced a new SQL migration to create the `forward_auth_settings` table for managing authentication domains and their configurations.

This refactor enhances the clarity and maintainability of the forward authentication logic within the application.

* chore: remove PR quality workflow configuration

Deleted the `.github/workflows/pr-quality.yml` file, which contained the configuration for the PR Quality workflow. This removal streamlines the repository by eliminating unused workflow files.

* Delete .github/workflows/pr-quality.yml

* refactor: enhance forward authentication UI and API integration

- Updated the alert block in the HandleForwardAuth component to provide clearer requirements for deploying the authentication proxy.
- Added a DnsHelperModal to assist with DNS configuration in the ForwardAuthServers component.
- Refined API input schemas for forward authentication operations to improve type safety and clarity.
- Removed the obsolete forward-auth SSO design document to streamline documentation.

These changes improve the user experience and maintainability of the forward authentication feature across the application.

* feat: add SQL migration for lucky echo and update foreign key constraints

- Introduced a new SQL migration file `0171_lucky_echo.sql` to modify the foreign key constraint on the `sso_provider` table, changing the `ON DELETE` behavior from `cascade` to `set null`.
- Updated the journal to include the new migration version and its associated tag.
- Added a snapshot file for version 7 of the database schema, reflecting the current state of the `sso_provider` and other related tables.

These changes enhance the integrity of the database by ensuring that user references are set to null instead of being deleted when the referenced user is removed.

* refactor: improve path validation in Traefik configuration schema

- Enhanced the `apiReadTraefikConfig` schema by reintroducing path validation logic to prevent directory traversal attacks and unauthorized access.
- The validation now includes checks for null bytes and ensures paths start with a defined main Traefik path, improving security and robustness.

These changes strengthen the integrity of the configuration handling by ensuring only valid paths are accepted.

* fix: swarm health check fields not resetting to default values (#4558)

Fixes #4553

- Replace z.coerce.number() with a custom transform that converts empty strings to undefined instead of 0
- Add value={field.value ?? ""} to numeric inputs so they visually clear when reset to undefined

* fix: add docker cleanup toggle to remote server creation (#4559)

* fix: add docker cleanup toggle to remote server creation and update forms

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: use stop-first update order for all database services (#4560)

Docker Swarm's default start-first update order causes new database
containers to fail with 'DBPathInUse' because two containers compete
for the same data volume simultaneously. Docker then rolls back the
update, silently reverting any env var or config changes.

Using stop-first ensures the old container is stopped before the new
one starts, preventing volume lock conflicts across all database types.

Fixes #4550

* fix: respect gitProviders permissions in git provider UI (#4561)

* chore: bump dokploy version to v0.29.8

* fix: strip credentials from service-level API responses (#4564)

* fix: strip credentials from service-level API responses

Registry passwords and S3 destination credentials were being returned
in service `.one` tRPC endpoints to any user with service-level read
access. Reported by Nihon Kohden Corporation security team.

- Strip registry `password` from `findApplicationById` via Drizzle `columns: { password: false }`
- Strip destination `accessKey`/`secretAccessKey` from all DB service finders (postgres, mysql, mariadb, mongo, libsql, compose, backup, volume-backups)
- Add `findRegistryByIdWithCredentials` for internal use only
- Builders and upload utils now load registry credentials by ID at execution time
- `createRollback` enriches `fullContext` with registry credentials before persisting to DB so rollback execution has what it needs
- Remove `findApplicationByIdWithCredentials` and `ApplicationNestedWithCredentials` — no longer needed
- Backup execution utils load full destination via `findDestinationById` at runtime instead of reading from the joined relation

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* refactor: improve restore logging for database backups (#4566)

* refactor: improve restore logging for database backups

- Updated restore functions across various database types (Postgres, MySQL, MongoDB, MariaDB, LibSQL, and Compose) to provide clearer logging messages.
- Replaced generic command execution logs with specific messages indicating the database being restored and the source backup file.
- This change enhances the clarity of restore operations and aids in troubleshooting by providing more context in the logs.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: use swarm advertise address in docker swarm join command (#4567)

* fix: enforce docker:read on container start/stop/kill/restart mutations (#4568)

* refactor: replace BETTER_AUTH_SECRET with betterAuthSecret in forward-auth setup

* fix: update deriveCookieSecret to meet oauth2-proxy requirements

* fix: correct deriveCookieSecret test to validate 16-byte hex secret as per oauth2-proxy requirements

* fix: strip credentials from gitProvider.getAll API response (#4569)

* fix: strip credentials from gitProvider.getAll API response

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: correct git provider access check for existing deploys (#4570)

* fix: use canEditDeployGitSource for git provider access on existing deploys

Replaces the simple userId ownership check with a new canEditDeployGitSource
function that correctly handles all role/sharing scenarios. Owner always has
access; admin and member only if they own the provider or it is shared with
the org — being assigned via accessedGitProviders (enterprise) only grants
permission to connect new deploys, not to edit the git source of existing ones.

Adds 26 unit tests covering owner, admin, member (with/without enterprise
license), shared providers, and the key regression case from issue #4469.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: prevent registry password from appearing in error messages and shell commands (#4579)

---------

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com>
Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com>
Co-authored-by: Francis <9560564+Baker@users.noreply.github.com>
Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com>
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
Co-authored-by: Jasael <67719321+jasael@users.noreply.github.com>
Co-authored-by: Philippe Parage <69145356+pparage@users.noreply.github.com>
Co-authored-by: youcef zr <93142224+youcefzemmar@users.noreply.github.com>
2026-06-08 09:20:55 -06:00
Mauricio Siu 60867d0b60 Merge pull request #4537 from Dokploy/canary
🚀 Release v0.29.7
2026-06-02 02:31:10 -06:00
Mauricio Siu 6a0acd9cad fix: update schedule scoping from user to organization
Changed the schedule scoping in the schedule utility to use organizationId instead of userId, ensuring that schedules are shared across all owners and admins within the same organization. This aligns with the recent changes to enhance organizational resource management.
2026-06-02 02:17:51 -06:00
Mauricio Siu 64a606ffa4 refactor: streamline imports and enhance permission test readability
Consolidated import statements across multiple provider components by removing duplicate imports of VALID_BRANCH_REGEX. Improved the readability of the permission test for denied access by simplifying the expect statement. Additionally, added copy-to-clipboard functionality in relevant components to enhance user experience.
2026-06-02 02:17:51 -06:00
Mauricio Siu 29851491f6 chore: update version to v0.29.7 in package.json and enhance permission tests
Bumped the version of dokploy to v0.29.7. Updated test descriptions for clarity, specifically renaming the test suite to reflect the roles of "owner" and "admin." Added new tests to ensure that members are denied access to various org-level enterprise resources, improving coverage and validation of permission checks.
2026-06-02 02:17:50 -06:00
Mauricio Siu 95633b4122 fix: refine permission check for privileged static roles in permission service
Updated the permission check logic to specifically identify "owner" and "admin" roles as privileged static roles, enhancing clarity and accuracy in permission validation. This change ensures that only users with these roles are granted access to enterprise-only resources.
2026-06-02 02:17:50 -06:00
Mauricio Siu c73632cbe0 fix: scope dokploy-server schedules to organization instead of user (#4526)
* fix: scope dokploy-server schedules to organization instead of user

Replaces userId with organizationId on the schedule table so that
global (dokploy-server) schedules are shared across all owners and
admins of the same organization, while remaining isolated between
different organizations.

Includes a data migration that backfills organizationId from the
owner membership record for any existing dokploy-server schedules.

Closes #4300

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-02 02:17:49 -06:00
github-actions[bot] 30b3e1fe48 🚀 Release v0.29.6 (#4514)
* fix(migrate-auth-secret): exit cleanly when there are no 2FA records

The empty-records branch of `main()` returned without calling
`process.exit(0)`, leaving the Drizzle Postgres connection pool
holding the event loop open. The `migrate-auth-secret` process
then hangs indefinitely after printing "No 2FA records found,
nothing to migrate." causing the upstream `0.29.3.sh` security
migration script (which calls this via `docker exec`) to never
reach its final `docker service update` step that mounts the new
Docker Secret. Operators end up with the new secret created but
the dokploy service still configured with the hardcoded
`BETTER_AUTH_SECRET`, while believing the migration completed.

Match the success branch a few lines below which already does
`process.exit(0)`, and the pattern used in sibling scripts
`reset-password.ts` and `reset-2fa.ts`.

Closes #4392

* feat(compose): add import from base64 in create service dropdown

Adds an "Import" option to the Create Service dropdown that lets users
paste a base64-encoded compose export, preview the template (compose YAML,
domains, envs, mounts) before confirming, and create the service only on
confirm. Adds a `previewTemplate` tRPC procedure that processes the base64
without touching the DB, with server access validation via session.

* [autofix.ci] apply automated fixes

* Enhance version synchronization workflow to include SDK repository

- Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories.
- Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec.
- Improved commit message formatting to include source and release information for all repositories.
- Ensured successful synchronization messages for each repository after the version update.

* feat(deployment): add readLogs procedure to fetch deployment logs

- Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter.
- Implemented permission checks to ensure users have access to the requested logs.
- Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context.

Resolve https://github.com/Dokploy/mcp/issues/14

* feat(deployment): add server access validation for deployment actions

- Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization.
- Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization.

This enhancement improves security and access control within the deployment management system.

* feat(organization): prevent inviting users with owner role

- Added validation to prevent users from being invited with the owner role in the organization and user routers.
- Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role.
This change enhances role management and security within the organization structure.

https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh

* feat(user): implement session cleanup on user update

- Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active.
- This change enhances security by preventing unauthorized access from previous sessions after a password change.

Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3

* feat(settings): add copy button to server IP in web server settings (#4397)

* fix: copy Dokploy server IP when clicking server badge (#4390)

* fix: copy Dokploy server IP when clicking server badge

When a service runs on the local Dokploy server (no remote server),
clicking the server badge did nothing because `data.server` is null.
Now falls back to the server IP from settings so the badge always
copies an IP address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(copy-ip): implement IP address copying functionality across database service components

- Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis).
- Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action.
- Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>

* fix: responsive layout (#4391)

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>

* fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382)

* fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468)

* fix: allow square brackets in zip drop path validation for Next.js dynamic routes

ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts,
pages/[slug].tsx) were rejected by readValidDirectory because the path regex
did not include square bracket characters.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: prevent webhook deploy crash when commit data lacks modified files (#4470)

shouldDeploy passed undefined/null entries from commit.modified straight
into micromatch, which throws "Expected input to be a string" and fails
every webhook deployment when watch paths are configured. Filter out
non-string values before matching.

* fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)

Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>

* fix: enable comment toggle shortcut in env variable editor (#4402) (#4473)

* fix: add tls=true label for domains when certificateType is none (#4018) (#4474)

* fix: add tls=true label for compose domains when certificateType is none (#4018)

* test: cover tls=true label for certificateType none, require https

* fix: scope tls fix to compose labels, leave traefik file config unchanged (#4018)

* chore: update version to v0.29.5 in package.json

* chore(deps): upgrade next to 16.2.6 (#4477)

Upgraded next dependency in apps/dokploy to 16.2.6 exactly. Verified typescript typecheck passes successfully.

* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso) (#4511)

* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso)

- Add `remoteServersOnly` field to webServerSettings: prevents creating services
  on the local Dokploy VM, forcing all deployments to remote servers. Validated
  in all 8 service routers (application, compose, postgres, mysql, mongo, redis,
  mariadb, libsql).
- Add `enforceSSO` field to webServerSettings: hides the email/password login
  form and shows only the SSO button on the login page.
- Both settings are enterprise-only (enterpriseProcedure) and self-hosted-only
  (blocked at the API level when IS_CLOUD=true).
- UI toggles added to the SSO settings page under a new "Self-hosted
  Restrictions" card (hidden in cloud). Login page reads enforceSSO from
  getServerSideProps to avoid client-side flash.
- Migrations: 0167_fresh_goliath.sql, 0168_long_justice.sql

* fix: add missing final newlines to migration files

* refactor: improve code formatting for better readability in multiple components

- Adjusted formatting in `add-application.tsx`, `add-compose.tsx`, and `add-database.tsx` to enhance readability by adding line breaks and consistent indentation.
- Updated `toggle-enforce-sso.tsx` to simplify the Switch component's props.
- Reformatted imports in `index.tsx` and `sso.tsx` for consistency.
- Cleaned up conditional statements in various router files for improved clarity.

* fix: add enforceSSO to test mock

* fix: grant create and delete SSH key permissions when canAccessToSSHKeys is enabled for members (#4512)

* fix: use create permission for basic auth delete instead of delete (#4513)

* fix: wrap long server names and keep actions menu visible (#4434)

On settings/servers, a long server name in the card title (h3) did not
wrap and overflowed its container, overlapping nearby content and
squeezing the three-dots actions menu until it disappeared.

Allow the title block to shrink and wrap (min-w-0 + break-words), keep
the server icon and the actions trigger from being crushed (shrink-0),
and add gap between the title and the actions button.

* chore: update version to v0.29.6 in package.json

* fix: preserve HOME in compose deploy so --with-registry-auth can read docker config (#4485)

The compose/stack deploy command runs under `env -i PATH="$PATH"`, which
clears the environment except for PATH. That strips HOME, so when the
generated command is `docker stack deploy --prune --with-registry-auth`
the docker CLI cannot resolve `~/.docker/config.json` (e.g.
`/root/.docker/config.json`) and ships no registry credentials to the
swarm. Private-registry images then fail to pull on the nodes:

  image registry.example.com/... could not be accessed on a registry to
  record its digest. Each node will access ... independently

while the deploy still logs "Docker Compose Deployed: ".

Keep PATH isolation but preserve HOME so docker can read its config for
both `stack deploy --with-registry-auth` and `compose up -d --build`.

Add a regression test asserting the generated command preserves
`HOME="$HOME"` for both stack and docker-compose deploys.

Fixes #4401

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com>
Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com>
Co-authored-by: Francis <9560564+Baker@users.noreply.github.com>
Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com>
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
Co-authored-by: Jasael <67719321+jasael@users.noreply.github.com>
Co-authored-by: Philippe Parage <69145356+pparage@users.noreply.github.com>
Co-authored-by: youcef zr <93142224+youcefzemmar@users.noreply.github.com>
2026-05-30 16:01:52 -06:00
github-actions[bot] a07106d649 🚀 Release v0.29.5 (#4475)
* fix(migrate-auth-secret): exit cleanly when there are no 2FA records

The empty-records branch of `main()` returned without calling
`process.exit(0)`, leaving the Drizzle Postgres connection pool
holding the event loop open. The `migrate-auth-secret` process
then hangs indefinitely after printing "No 2FA records found,
nothing to migrate." causing the upstream `0.29.3.sh` security
migration script (which calls this via `docker exec`) to never
reach its final `docker service update` step that mounts the new
Docker Secret. Operators end up with the new secret created but
the dokploy service still configured with the hardcoded
`BETTER_AUTH_SECRET`, while believing the migration completed.

Match the success branch a few lines below which already does
`process.exit(0)`, and the pattern used in sibling scripts
`reset-password.ts` and `reset-2fa.ts`.

Closes #4392

* feat(compose): add import from base64 in create service dropdown

Adds an "Import" option to the Create Service dropdown that lets users
paste a base64-encoded compose export, preview the template (compose YAML,
domains, envs, mounts) before confirming, and create the service only on
confirm. Adds a `previewTemplate` tRPC procedure that processes the base64
without touching the DB, with server access validation via session.

* [autofix.ci] apply automated fixes

* Enhance version synchronization workflow to include SDK repository

- Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories.
- Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec.
- Improved commit message formatting to include source and release information for all repositories.
- Ensured successful synchronization messages for each repository after the version update.

* feat(deployment): add readLogs procedure to fetch deployment logs

- Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter.
- Implemented permission checks to ensure users have access to the requested logs.
- Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context.

Resolve https://github.com/Dokploy/mcp/issues/14

* feat(deployment): add server access validation for deployment actions

- Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization.
- Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization.

This enhancement improves security and access control within the deployment management system.

* feat(organization): prevent inviting users with owner role

- Added validation to prevent users from being invited with the owner role in the organization and user routers.
- Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role.
This change enhances role management and security within the organization structure.

https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh

* feat(user): implement session cleanup on user update

- Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active.
- This change enhances security by preventing unauthorized access from previous sessions after a password change.

Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3

* feat(settings): add copy button to server IP in web server settings (#4397)

* fix: copy Dokploy server IP when clicking server badge (#4390)

* fix: copy Dokploy server IP when clicking server badge

When a service runs on the local Dokploy server (no remote server),
clicking the server badge did nothing because `data.server` is null.
Now falls back to the server IP from settings so the badge always
copies an IP address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(copy-ip): implement IP address copying functionality across database service components

- Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis).
- Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action.
- Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>

* fix: responsive layout (#4391)

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>

* fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382)

* fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468)

* fix: allow square brackets in zip drop path validation for Next.js dynamic routes

ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts,
pages/[slug].tsx) were rejected by readValidDirectory because the path regex
did not include square bracket characters.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

* fix: prevent webhook deploy crash when commit data lacks modified files (#4470)

shouldDeploy passed undefined/null entries from commit.modified straight
into micromatch, which throws "Expected input to be a string" and fails
every webhook deployment when watch paths are configured. Filter out
non-string values before matching.

* fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)

Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>

* fix: enable comment toggle shortcut in env variable editor (#4402) (#4473)

* fix: add tls=true label for domains when certificateType is none (#4018) (#4474)

* fix: add tls=true label for compose domains when certificateType is none (#4018)

* test: cover tls=true label for certificateType none, require https

* fix: scope tls fix to compose labels, leave traefik file config unchanged (#4018)

* chore: update version to v0.29.5 in package.json

---------

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com>
Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com>
Co-authored-by: Francis <9560564+Baker@users.noreply.github.com>
Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com>
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
2026-05-22 17:21:12 -06:00
Mauricio Siu df98cea19f Merge pull request #4381 from Dokploy/canary
🚀 Release v0.29.4
2026-05-11 13:46:38 -06:00
Mauricio Siu b109e0ebc4 Merge pull request #4380 from Dokploy/4379-deployments-does-not-load-in-v0293
fix(wss): add colon to directory validation regex to fix deployment logs loading
2026-05-11 13:34:55 -06:00
Mauricio Siu 282d358d04 fix(validation): update regex for directory validation in WebSocket utility
- Modified the regex pattern in the `readValidDirectory` function to allow for a wider range of characters, including colons, improving the validation of directory names.
- This change enhances input integrity by ensuring valid directory formats are accepted.
2026-05-11 13:34:13 -06:00
Mauricio Siu 2f08b33931 feat(sync): add job to sync OpenAPI specification to SDK repository
- Implemented a new workflow step to clone the SDK repository and update the OpenAPI specification.
- Configured Git user details for the sync operation and added a commit message format that includes source and update timestamp.
- Ensured successful synchronization of OpenAPI documentation to the SDK repository.
2026-05-11 13:12:08 -06:00
Mauricio Siu ccc8f6d047 Merge pull request #4372 from Dokploy/canary
🚀 Release v0.29.3
2026-05-11 11:57:12 -06:00
Mauricio Siu 62aeed5aed fix(esbuild): update path for migrate-auth-secret script
- Changed the path of the `migrate-auth-secret` script from the root directory to the `scripts` folder for better organization and clarity in the project structure.
2026-05-11 11:34:21 -06:00
Mauricio Siu 5e021797f3 feat(validation): standardize branch name validation across provider schemas
- Added a regex validation for branch names in Bitbucket, Git, Gitea, GitHub, and GitLab provider schemas to ensure consistent and valid branch formats.
- Refactored the branch validation logic to improve readability and maintainability across the schemas.
- Enhanced input integrity by ensuring all provider schemas adhere to the same branch name validation rules.
2026-05-11 11:22:05 -06:00
Mauricio Siu 1c6fdc1b43 Merge pull request #4374 from Dokploy/fix/better-auth-secret-hardcoded
fix(security): replace hardcoded BETTER_AUTH_SECRET with Docker secret support
2026-05-09 02:10:41 -06:00
autofix-ci[bot] 6270bad9af [autofix.ci] apply automated fixes 2026-05-09 08:08:34 +00:00
Mauricio Siu 9c71458eff feat(auth): implement migration script for auth secret and refactor secret handling
- Added a new script `migrate-auth-secret.ts` to facilitate the migration of 2FA secrets when changing the BETTER_AUTH_SECRET.
- Updated `package.json` to include a command for running the migration script.
- Refactored the handling of BETTER_AUTH_SECRET to improve security by removing the hardcoded default and introducing a fallback mechanism using environment variables or Docker secrets.
- Updated the authentication logic to utilize the new `betterAuthSecret` function for retrieving the secret.
2026-05-09 02:08:04 -06:00
Mauricio Siu 547ba2d04b feat(validation): enhance registry URL validation in schema
- Introduced a new validation schema for registry URLs, ensuring they conform to a specific format (hostname[:port]) and disallow shell metacharacters.
- Updated the `createSchema`, `apiCreateRegistry`, and `apiTestRegistry` functions to utilize the new registry URL validation.
- Improved security and input integrity for registry URL fields.
- Updated the `removeRegistry` function to escape the registry URL during logout to prevent command injection vulnerabilities.
2026-05-09 01:09:50 -06:00
Mauricio Siu b9e97eb321 feat(validation): enhance destination path validation in file upload schema
- Updated the `destinationPath` field in the upload file schema to include a regex validation, ensuring only alphanumeric characters, dots, dashes, underscores, and forward slashes are allowed.
- Added a corresponding regex check in the `uploadFileToContainer` function to validate the destination path before processing, improving input integrity and preventing errors.
2026-05-09 00:57:12 -06:00
Mauricio Siu a4e2317f3e feat(deployment): enhance log retrieval by encoding log path in base64
- Updated the WebSocket server to encode the log path in base64 before executing the tail command on the remote server.
- Added validation to ensure the directory name adheres to a specified regex pattern, improving input integrity for directory paths.
2026-05-09 00:01:45 -06:00
Mauricio Siu 06a349152f fix(traefik): update remote config writing to use base64 encoding
- Modified the `writeConfigRemote` function to encode the Traefik configuration in base64 before saving it to the remote YAML file.
- This change ensures that the configuration is correctly handled and prevents potential issues with special characters in the config.
2026-05-08 23:54:40 -06:00
Mauricio Siu fef2de1ec5 feat(validation): add branch name validation across provider schemas
- Introduced a regex validation for branch names in Bitbucket, Git, Gitea, GitHub, and GitLab provider schemas to ensure valid branch formats.
- Updated the corresponding schemas to include the new validation rule, enhancing input integrity and preventing potential errors.
- Added a utility for branch validation in the server utils.
2026-05-08 23:50:38 -06:00
Mauricio Siu b20ff64cbf chore(package): bump version to v0.29.3 2026-05-08 23:27:47 -06:00
Mauricio Siu 5177580d51 Merge pull request #4371 from Dokploy/feat/schedule-description
feat(schedules): add optional description field
2026-05-08 23:19:12 -06:00
Mauricio Siu d3292a2810 feat(schedules): add optional description field to schedule form and display
- Updated the schedule form schema to include an optional 'description' field.
- Enhanced the form to allow users to input a description for each schedule.
- Modified the schedule display component to show the description if available.
- Added a database migration to include the 'description' column in the schedule table.
2026-05-08 23:15:04 -06:00
Mauricio Siu 0f526af2c8 Merge pull request #4370 from Dokploy/fix/template-isolated-deployment
feat(templates): support isolated = false opt-out in template.toml
2026-05-08 19:34:07 -06:00
autofix-ci[bot] 72f5d711c8 [autofix.ci] apply automated fixes 2026-05-09 01:32:34 +00:00
Mauricio Siu ffd51cf32f feat(templates): add isolated deployment configuration to CompleteTemplate
Introduced an optional 'isolated' boolean property in the CompleteTemplate interface to manage isolated deployment settings. Added tests to verify default behavior (isolated=true) and explicit settings (isolated=true/false) in the deployment configuration.

This change enhances template flexibility for deployment configurations.
2026-05-08 19:32:05 -06:00
Mauricio Siu e8b3d7ba7d test(templates): add unit tests for isolated deployment config field 2026-05-08 19:26:34 -06:00
Mauricio Siu c182755591 feat(templates): support isolated = false opt-out in template.toml
Templates using network_mode: host (e.g. cloudflared) can now declare
isolated = false in their [config] section to prevent Dokploy from
injecting networks into the compose, which would cause a Docker error.

Default behavior (isolated = true) is unchanged for all existing templates.

Fixes #4366
2026-05-08 19:22:00 -06:00
Mauricio Siu 8227a48ef4 Merge pull request #4368 from Dokploy/fix/replace-traefik-me-with-sslip-io
fix: replace traefik.me with sslip.io for auto-generated domains
2026-05-08 19:05:33 -06:00
Mauricio Siu f5ddc36f24 fix: replace traefik.me with sslip.io for auto-generated domains
Fixes #4365 — traefik.me had availability issues. sslip.io uses the same
IP-in-subdomain format, supports both IPv4 and IPv6, and is more reliable.
2026-05-08 19:04:24 -06:00
Mauricio Siu d5d8914bf6 Merge pull request #4358 from nhridoy/fix/layout
fix: UI Responsiveness for both mobile, Tab and desktop Screens
2026-05-08 18:49:32 -06:00
autofix-ci[bot] bf0890a6b0 [autofix.ci] apply automated fixes 2026-05-09 00:48:16 +00:00
Mauricio Siu 4e07669464 Merge branch 'canary' into fix/layout 2026-05-08 18:47:39 -06:00
Mauricio Siu 4a3fa6e63f fix: reorder imports and clean up unused ones across various components 2026-05-08 18:45:44 -06:00
autofix-ci[bot] 14af5d293a [autofix.ci] apply automated fixes 2026-05-07 20:41:26 +00:00
Mauricio Siu 746bb3ddc6 Merge pull request #4338 from BradPerbs/fix/remove-debug-console-logs
fix: remove leftover debug console.log statements
2026-05-07 14:35:28 -06:00
Mauricio Siu b13308dc69 Merge pull request #4294 from berkay-digital/feat/copy-ai-log-analysis
feat: add copy button to AI log analysis result
2026-05-07 13:46:07 -06:00
Mauricio Siu 16746a1609 Merge pull request #4345 from amit-y11/fix/project-service-card-alignment
fix: align card footers to bottom on project and service cards
2026-05-07 13:35:26 -06:00
Nahidujjaman Hridoy bca62d43d2 fix: ui responsiveness for mobile, tab and desktop screens
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 17:49:49 +06:00
Nahidujjaman Hridoy d502f4a206 fix: ui responsiveness for mobile, tab and desktop screens
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 17:49:16 +06:00
Nahidujjaman Hridoy de7d6f8147 fix: responsiveness in components/dashboard/settings/web-domain.tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 13:38:10 +06:00
Nahidujjaman Hridoy 9d6bc4cd18 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 12:26:27 +06:00
Nahidujjaman Hridoy 65b27af0f5 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 12:26:17 +06:00
Nahidujjaman Hridoy 6165114bc3 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 12:24:13 +06:00
Nahidujjaman Hridoy d3109359fb fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-06 22:57:17 +06:00
Nahidujjaman Hridoy 58f527d029 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-06 22:23:44 +06:00
Amit 1ed41fe2f8 fix: align card footers to bottom on project and service cards
Cards with descriptions appeared taller than those without, causing
the 'Created at' and service count text to be misaligned across cards
in the same row.

- Add flex-col and mt-auto to project cards so footer sticks to bottom
- Add h-full to service card Link and Card so they fill the grid cell
2026-05-02 22:40:38 +05:30
Mauricio Siu 9b416b3699 Merge pull request #4341 from Dokploy/fix/sidebar-mobile-close-on-navigation
fix(sidebar): close mobile sidebar on navigation
2026-05-01 13:15:45 -06:00
Mauricio Siu 096b8b33fc fix(sidebar): close mobile sidebar on navigation
Closes #4340
2026-05-01 13:15:20 -06:00
BPx33 741792883a fix: remove leftover debug console.log statements
Three stray debug console.log calls had been left in production code paths:

- restore-backup.tsx: logged the full form payload on every backup restore
  submission.
- add-database.tsx: logged the libsql `enableNamespaces` field value from
  inside a `render` callback, firing on every re-render of the parent form.
- project.ts (duplicateProject mutation): logged the freshly-created
  target project/environment row on the server on every duplicate.

No behaviour change; strictly removes log noise.
2026-05-01 16:24:28 +08:00
Mauricio Siu e0c6ed699d Merge pull request #4336 from Dokploy/fix/template-fetch-timeout-error-handling
fix(templates): add fetch timeout and handle network errors gracefully
2026-04-30 18:52:45 -06:00
Mauricio Siu 5f5ed0f2c2 fix(templates): add fetch timeout and handle network errors gracefully
Add 10s AbortSignal timeout to all template fetch calls so they fail
cleanly instead of hanging indefinitely when templates.dokploy.com is
unreachable. Add try/catch to getTags endpoint which was missing error
handling, causing a 500 instead of returning an empty list.

Closes #4282
2026-04-30 18:52:16 -06:00
Mauricio Siu b9ff576682 Merge pull request #4335 from Dokploy/fix/auth-redirect-permanent-cache
fix: use temporary redirects for auth checks in getServerSideProps
2026-04-30 18:47:17 -06:00
Mauricio Siu c854a38adb fix: use temporary redirects for auth checks in getServerSideProps
Replace permanent (301) redirects with temporary (302) redirects across
all pages that check authentication state in getServerSideProps.

Permanent redirects are cached by the browser indefinitely, causing a
bug where users had to manually refresh after login: the browser would
cache the unauthenticated redirect (dashboard → login page) and replay
it even after a successful login, preventing navigation to the dashboard.

Fixes #4220
2026-04-30 18:46:12 -06:00
Mauricio Siu 5fb365c08b chore(package.json): add peerDependencyRules to ignore missing dependencies 2026-04-30 18:31:07 -06:00
Mauricio Siu 15296d5c85 fix(compose-file-editor): simplify form reset logic in ComposeFileEditor component 2026-04-30 09:50:27 -06:00
Mauricio Siu 0e5fc584b2 Merge pull request #4278 from mixelburg/fix/webhook-401-missing-signature
fix(webhook): return 401 when signature header is missing
2026-04-28 18:30:48 -06:00
Mauricio Siu cc7ea5108b Merge pull request #4325 from Dokploy/fix/3909-healthcheck-interval
fix: reduce healthcheck frequency to lower memory pressure
2026-04-28 18:27:20 -06:00
Mauricio Siu 8f3d824ea6 fix: reduce healthcheck frequency to lower memory pressure
Closes #3909

The previous 10s interval triggered a DB query (SELECT 1) 360 times/hour,
keeping Node.js heap under constant pressure and preventing the GC from
reclaiming memory efficiently. Increasing to 30s reduces query load by 3x.
Also adds start-period=60s to avoid false failures during startup.
2026-04-28 18:26:52 -06:00
Mauricio Siu 0bdcbf5827 Merge pull request #4323 from Dokploy/fix/forgot-password-email-max-length
fix: enforce 255-char max length on forgot password email field
2026-04-28 18:09:35 -06:00
autofix-ci[bot] 34564aec84 [autofix.ci] apply automated fixes 2026-04-29 00:09:17 +00:00
Mauricio Siu ed006dc5f9 fix: enforce email length validation in reset password form
- Added a maximum length constraint of 255 characters for the email input field.
- Updated validation schema to include a message for exceeding the maximum length.
2026-04-28 18:08:37 -06:00
Mauricio Siu 222b167a76 Merge pull request #4299 from Dokploy/canary
🚀 Release v0.29.2
2026-04-24 22:46:42 -06:00
berkay-digital ad490dca3f feat: add copy button to AI log analysis result
Allows users to quickly copy the AI-generated log analysis to their
clipboard from the analyze-logs popover, matching the copy UX used in
the deployment and docker logs views.

Made-with: Cursor
2026-04-23 15:19:13 +02:00
autofix-ci[bot] ce703ef478 [autofix.ci] apply automated fixes 2026-04-22 00:05:08 +00:00
OpenClaw Bot fc6df3ae05 fix(webhook): cast signature to string to fix TS2345 2026-04-22 00:04:44 +00:00
autofix-ci[bot] 8fb517152a [autofix.ci] apply automated fixes 2026-04-21 22:04:36 +00:00
Maks Pikov ba3591b3ac fix(webhook): return 401 when signature header is missing 2026-04-21 22:03:55 +00:00
Mauricio Siu bad9731878 Merge pull request #4261 from Dokploy/canary
🚀 Release v0.29.1
2026-04-20 07:16:11 -06:00
Mauricio Siu 7e13243c1d Merge pull request #4155 from Dokploy/canary
🚀 Release v0.29.0
2026-04-17 14:10:37 -06:00
Mauricio Siu 4d8a2a38e8 Merge pull request #4029 from Dokploy/canary
🚀 Release v0.28.8
2026-03-18 21:43:35 -06:00
Mauricio Siu de3db08e60 Merge pull request #4020 from Dokploy/canary
🚀 Release v0.28.7
2026-03-17 23:34:20 -06:00
Mauricio Siu a2d655083a Merge pull request #3965 from Dokploy/canary
🚀 Release v0.28.6
2026-03-10 10:18:15 -06:00
Mauricio Siu f3356cfe90 Merge pull request #3938 from Dokploy/canary
🚀 Release v0.28.5
2026-03-09 00:13:30 -06:00
Mauricio Siu 2362778fe1 Merge pull request #3907 from Dokploy/canary
🚀 Release v0.28.4
2026-03-06 11:51:08 -06:00
Mauricio Siu 628f16e8cb fix: update import statements to include file extensions for consistency 2026-03-03 15:34:56 -06:00
Mauricio Siu ea8e99d76d Merge pull request #3875 from Dokploy/canary
🚀 Release v0.28.3
2026-03-03 15:05:02 -06:00
Mauricio Siu d4719ece58 Merge pull request #3845 from Dokploy/canary
🚀 Release v0.28.2
2026-03-01 00:36:46 -06:00
Mauricio Siu e679a322b9 Merge pull request #3825 from Dokploy/canary
🚀 Release v0.28.1
2026-02-27 14:02:42 -06:00
Mauricio Siu f24f1ada5f Merge pull request #3805 from Dokploy/canary
🚀 Release v0.28.0
2026-02-27 02:02:04 -06:00
Mauricio Siu 5b6d80e177 Merge pull request #3682 from Dokploy/canary
🚀 Release v0.27.1
2026-02-18 01:54:44 -06:00
Mauricio Siu 2c9ca651a8 Merge pull request #3668 from Dokploy/canary
refactor(deployments): enhance deployment worker and queue handling f…
2026-02-10 03:16:04 -06:00
Mauricio Siu 413ed9bd80 Merge pull request #3604 from Dokploy/canary
🚀 Release v0.27.0
2026-02-10 02:06:41 -06:00
Mauricio Siu 4f578516d6 Merge pull request #3570 from Dokploy/canary
🚀 Release v0.26.7
2026-01-31 05:06:50 -06:00
Mauricio Siu 1e57d48ab4 Merge pull request #3499 from Dokploy/canary
🚀 Release v0.26.6
2026-01-27 13:42:24 -06:00
Mauricio Siu a177d34dfd Merge pull request #3456 from Dokploy/canary
🚀 Release v0.26.5
2026-01-15 09:25:26 -06:00
Mauricio Siu 1034c79245 Merge pull request #3442 from Dokploy/canary
🚀 Release v0.26.4
2026-01-15 01:55:28 -06:00
Mauricio Siu 304454b22d Merge pull request #3312 from Dokploy/canary
🚀 Release v0.26.3
2026-01-01 22:37:09 -06:00
Mauricio Siu 42c2076281 Merge pull request #3254 from Dokploy/canary
🚀 Release v0.26.2
2025-12-13 01:41:50 -06:00
Mauricio Siu 5cd7de8188 Merge pull request #3211 from Dokploy/canary
🚀 Release v0.26.1
2025-12-10 00:47:12 -06:00
Mauricio Siu 1352b859e2 Merge pull request #3166 from Dokploy/canary
🚀 Release v0.26.0
2025-12-08 14:37:15 -06:00
Mauricio Siu 1c2307b86f Merge pull request #3114 from Dokploy/canary
🚀 Release v0.25.11
2025-11-26 03:41:51 -05:00
Mauricio Siu 4832fd929c Merge pull request #3072 from Dokploy/canary
🚀 Release v0.25.10
2025-11-20 08:59:37 -06:00
Mauricio Siu d1b639a55a Merge pull request #3063 from Dokploy/canary
🚀 Release v0.25.9
2025-11-19 23:10:38 -06:00
Mauricio Siu 40de13e4d4 Merge pull request #3055 from Dokploy/canary
🚀 Release v0.25.8
2025-11-19 02:45:33 -06:00
Mauricio Siu f0ea1c8796 Merge pull request #3043 from Dokploy/canary
🚀 Release v0.25.7
2025-11-18 23:01:38 -06:00
Mauricio Siu b45e7e415c Merge pull request #2901 from Dokploy/canary
🚀 Release v0.25.6
2025-10-26 02:14:56 -06:00
Mauricio Siu 67d3e92aaf Merge pull request #2765 from Dokploy/canary
🚀 Release v0.25.5
2025-10-05 23:06:46 -06:00
Mauricio Siu 76af74d8aa Merge pull request #2721 from Dokploy/canary
🚀 Release v0.25.4
2025-09-29 23:06:29 -06:00
Mauricio Siu b15ede8877 Merge pull request #2658 from Dokploy/canary
🚀 Release v0.25.3
2025-09-21 16:25:37 -06:00
Mauricio Siu ea805c1520 Merge pull request #2612 from Dokploy/canary
🚀 Release v0.25.2
2025-09-15 23:44:43 -06:00
Mauricio Siu 976932fb03 Merge pull request #2557 from Dokploy/canary
🚀 Release v0.25.1
2025-09-07 14:03:25 -06:00
Mauricio Siu ac8960efdd Merge pull request #2483 from Dokploy/canary
🚀 Release v0.25.0
2025-09-06 22:41:45 -06:00
Mauricio Siu d6050ce05a Merge pull request #2408 from Dokploy/canary
🚀 Release v0.24.12
2025-08-19 00:44:43 -06:00
Mauricio Siu 5a46b879f5 Merge pull request #2390 from Dokploy/canary
🚀 Release v0.24.11
2025-08-17 15:26:03 -06:00
Mauricio Siu 222e4878bd Merge pull request #2360 from Dokploy/canary
🚀 Release v0.24.10
2025-08-10 23:28:58 -06:00
Mauricio Siu fd267a64de Merge pull request #2354 from Dokploy/canary
🚀 Release v0.24.9
2025-08-10 06:13:01 -06:00
Mauricio Siu fa3cdf148b Merge pull request #2324 from Dokploy/canary
🚀 Release v0.24.8
2025-08-04 00:28:40 -06:00
Mauricio Siu 74caf141f4 Merge pull request #2323 from Dokploy/canary
🚀 Release v0.24.7
2025-08-03 18:58:05 -06:00
Mauricio Siu 8b7d9c0896 Merge pull request #2303 from Dokploy/canary
🚀 Release v0.24.6
2025-08-03 02:42:22 -06:00
Mauricio Siu 13e20e9ef8 Merge pull request #2253 from Dokploy/canary
🚀 Release v0.24.5
2025-07-28 02:15:50 -06:00
Mauricio Siu f9b0589070 Merge pull request #2219 from Dokploy/canary
🚀 Release v0.24.4
2025-07-20 20:40:02 -06:00
Mauricio Siu b615d04ad2 Merge pull request #2193 from Dokploy/canary
🚀 Release v0.24.3
2025-07-13 23:44:03 -06:00
Mauricio Siu 6c4efa48b1 Merge pull request #2191 from Dokploy/canary
🚀 Release v0.24.2
2025-07-13 20:46:50 -06:00
Mauricio Siu 85d48aba2b Merge pull request #2183 from Dokploy/canary
🚀 Release v0.24.1
2025-07-13 13:57:09 -06:00
Mauricio Siu 3b138f8e8a Merge pull request #2143 from Dokploy/canary
🚀 Release v0.24.0
2025-07-06 21:40:17 -06:00
Mauricio Siu b91067dc2a Merge pull request #2126 from Dokploy/canary
🚀 Release v0.23.7
2025-07-05 01:59:04 -06:00
Mauricio Siu 335a16b915 Merge pull request #2114 from Dokploy/canary
🚀 Release v0.23.6
2025-07-01 23:31:59 -06:00
Mauricio Siu 274f38029c Merge pull request #2103 from Dokploy/canary
🚀 Release v0.23.5
2025-06-29 14:14:02 -06:00
Mauricio Siu 4cbc91d3d0 Merge pull request #2091 from Dokploy/canary
🚀 Release v0.23.4
2025-06-27 00:09:51 -06:00
Mauricio Siu 10d17de186 Merge pull request #2070 from Dokploy/canary
🚀 Release v0.23.3
2025-06-22 18:36:40 +02:00
Mauricio Siu 65f0919fa7 Merge pull request #2068 from Dokploy/canary
🚀 Release v0.23.2
2025-06-22 16:46:53 +02:00
Mauricio Siu 9b7abfbed7 Merge pull request #2063 from Dokploy/canary
🚀 Release v0.23.1
2025-06-22 08:58:23 +02:00
Mauricio Siu 6676a86b34 Merge pull request #2061 from Dokploy/canary
🚀 Release v0.23.0
2025-06-22 08:27:01 +02:00
Mauricio Siu d603654ac1 Merge pull request #1965 from Dokploy/canary
🚀 Release v0.22.7
2025-05-28 02:52:40 -06:00
Mauricio Siu d9ffe519b0 Merge pull request #1920 from Dokploy/canary
🚀 Release v0.22.6
2025-05-18 02:32:42 -06:00
Mauricio Siu fa91a74462 Merge pull request #1911 from Dokploy/canary
🚀 Release v0.22.5
2025-05-17 16:19:10 -06:00
Mauricio Siu d7794286be Merge pull request #1871 from Dokploy/canary
🚀 Release v0.22.4
2025-05-10 20:59:48 -06:00
Mauricio Siu f337dd7e01 Merge pull request #1847 from Dokploy/canary
🚀 Release v0.22.3
2025-05-06 23:57:22 -06:00
Mauricio Siu 5d5d95bbd3 Merge pull request #1836 from Dokploy/canary
🚀 Release v0.22.2
2025-05-06 02:56:01 -06:00
Mauricio Siu 7be1084a10 Merge pull request #1828 from Dokploy/canary
🚀 Release v0.22.1
2025-05-05 03:10:22 -06:00
Mauricio Siu 19a525fac1 Merge pull request #1824 from Dokploy/canary
🚀 Release v0.22.0
2025-05-04 23:22:21 -06:00
Mauricio Siu 7984497398 Merge pull request #1785 from Dokploy/canary
🚀 Release v0.21.8
2025-04-27 00:18:55 -06:00
234 changed files with 55466 additions and 936 deletions
+79
View File
@@ -138,6 +138,8 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -160,3 +162,80 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
-21
View File
@@ -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
View File
@@ -110,3 +110,24 @@ jobs:
echo "✅ OpenAPI synced to CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to SDK repository successfully"
-83
View File
@@ -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
View File
@@ -4,5 +4,8 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
HEALTHCHECK --interval=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 ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
@@ -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,
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
};
describe("Host rule format validation", () => {
@@ -23,6 +23,7 @@ describe("createDomainLabels", () => {
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
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 () => {
const customPortDomain = { ...baseDomain, port: 3000 };
const labels = await createDomainLabels(appName, customPortDomain, "web");
@@ -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);
});
});
@@ -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();
});
describe("static roles bypass enterprise resources", () => {
describe("owner and admin bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -73,15 +73,8 @@ describe("static roles bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, {
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", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");
@@ -494,4 +494,49 @@ describe("processTemplate", () => {
expect(result.mounts).toHaveLength(1);
});
});
describe("isolated deployment config", () => {
it("should default to isolated=true when not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
},
};
expect(template.config.isolated).toBeUndefined();
// undefined !== false => isolatedDeployment = true
expect(template.config.isolated !== false).toBe(true);
});
it("should be isolated when isolated=true is explicitly set", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: true,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(true);
});
it("should disable isolated deployment when isolated=false", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: false,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(false);
});
});
});
@@ -30,9 +30,7 @@ describe("helpers functions", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
).toBeTruthy();
});
});
@@ -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,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
remoteServersOnly: false,
enforceSSO: false,
createdAt: null,
updatedAt: new Date(),
};
@@ -148,6 +148,7 @@ const baseDomain: Domain = {
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
const baseRedirect: Redirect = {
@@ -78,4 +78,20 @@ describe("readValidDirectory (path traversal)", () => {
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
it("returns true for Next.js dynamic route paths with square brackets", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
),
).toBe(true);
expect(
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
),
).toBe(true);
});
});
@@ -16,12 +16,17 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const optionalNumber = z
.union([z.string(), z.number()])
.transform((val) => (val === "" ? undefined : Number(val)))
.optional();
export const healthCheckFormSchema = z.object({
Test: z.array(z.string()).optional(),
Interval: z.coerce.number().optional(),
Timeout: z.coerce.number().optional(),
StartPeriod: z.coerce.number().optional(),
Retries: z.coerce.number().optional(),
Interval: optionalNumber,
Timeout: optionalNumber,
StartPeriod: optionalNumber,
Retries: optionalNumber,
});
interface HealthCheckFormProps {
@@ -195,7 +200,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -212,7 +222,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -229,7 +244,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -247,7 +267,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
unhealthy
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
<Input
type="number"
placeholder="3"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel className="text-base">Ulimits</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
@@ -21,9 +21,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import type { ValidationStates } from "./show-domains";
import { AddDomain } from "./handle-domain";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import type { ValidationStates } from "./show-domains";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
@@ -168,7 +168,7 @@ export const createColumns = ({
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("traefik.me") && (
{!domain.host.includes("sslip.io") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -256,7 +256,7 @@ export const createColumns = ({
return (
<div className="flex items-center gap-2">
{!domain.host.includes("traefik.me") && (
{!domain.host.includes("sslip.io") && (
<DnsHelperModal
domain={{
host: domain.host,
@@ -225,7 +225,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
const isTraefikMeDomain = host?.includes("sslip.io") || false;
useEffect(() => {
if (data) {
@@ -513,7 +513,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
field.value.includes("sslip.io") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
@@ -524,12 +524,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
to make your sslip.io domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<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
certificate options will not have any effect.
</AlertBlock>
@@ -567,7 +567,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
<p>Generate sslip.io domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -806,7 +806,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</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 { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
export type ValidationState = {
isLoading: boolean;
@@ -425,7 +426,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
)}
<div className="flex gap-2 flex-wrap">
{!item.host.includes("traefik.me") && (
{!item.host.includes("sslip.io") && (
<DnsHelperModal
domain={{
host: item.host,
@@ -453,6 +454,12 @@ export const ShowDomains = ({ id, type }: Props) => {
</Button>
</AddDomain>
)}
{canCreateDomain && type === "application" && (
<HandleForwardAuth
domainId={item.domainId}
applicationId={id}
/>
)}
{canDeleteDomain && (
<DialogAction
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 { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.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"),
watchPaths: z.array(z.string()).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 { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
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(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -107,110 +111,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-3">
<div className="flex items-center justify-between h-5">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</div>
<div className="space-y-4">
/>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="branch"
name="sshKey"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormItem className="col-span-2 lg:col-span-1">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<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>
<FormMessage />
</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
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormItem className="col-span-2">
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
@@ -223,7 +220,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormItem className="col-span-2 lg:col-span-4">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<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 { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -72,7 +73,10 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is 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"),
watchPaths: z.array(z.string()).default([]),
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 { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is 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"),
watchPaths: z.array(z.string()).optional(),
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 { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
id: z.number().nullable(),
})
.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"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</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}>
{canDeploy && (
<DialogAction
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
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" />
Open Terminal
</Button>
</DockerTerminalModal>
{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>
<Switch
aria-label="Toggle autodeploy"
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
)}
{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>
<Switch
aria-label="Toggle clean cache"
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
const isTraefikMeDomain = host?.includes("sslip.io") || false;
useEffect(() => {
if (data) {
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
<FormItem>
{isTraefikMeDomain && (
<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
certificate options will not have any effect.
</AlertBlock>
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
<p>Generate sslip.io domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
env: "",
wildcardDomain: "*.traefik.me",
wildcardDomain: "*.sslip.io",
port: 3000,
previewLimit: 3,
previewLabels: [],
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
wildcardDomain: data.previewWildcard || "*.sslip.io",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3,
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> 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
not have any effect.
</AlertBlock>
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormControl>
<Input placeholder="*.traefik.me" {...field} />
<Input placeholder="*.sslip.io" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -80,6 +80,7 @@ export const commonCronExpressions = [
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
@@ -224,6 +225,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
description: "",
cronExpression: "",
shellType: "bash",
command: "",
@@ -263,6 +265,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
if (scheduleId && schedule) {
form.reset({
name: schedule.name,
description: schedule.description || "",
cronExpression: schedule.cronExpression,
shellType: schedule.shellType,
command: schedule.command,
@@ -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
name="cronExpression"
formControl={form.control}
@@ -125,6 +125,11 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
{schedule.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{schedule.description && (
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
{schedule.description}
</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge
variant="outline"
@@ -2,6 +2,10 @@ import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -36,10 +40,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
const DockerLogsId = dynamic(
() =>
@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
const composeFile = form.watch("composeFile");
useEffect(() => {
if (data && !composeFile) {
if (data) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, form.reset, data]);
}, [form, data]);
useEffect(() => {
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 { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.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"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -418,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</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 { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
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(),
watchPaths: z.array(z.string()).optional(),
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 { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -57,7 +58,10 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is 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"),
watchPaths: z.array(z.string()).optional(),
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 { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is 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"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
@@ -445,7 +449,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</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 { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
gitlabPathNamespace: z.string().min(1),
})
.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"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -436,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -288,7 +288,6 @@ export const RestoreBackup = ({
toast.error("Please select a database type");
return;
}
console.log({ data });
setIsDeploying(true);
};
@@ -1,5 +1,14 @@
"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 { useState } from "react";
import ReactMarkdown from "react-markdown";
@@ -30,6 +39,7 @@ const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const [copied, setCopied] = useState(false);
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
@@ -52,6 +62,15 @@ export function AnalyzeLogs({ logs, context }: Props) {
mutate({ aiId, logs: logsText, context });
};
const handleCopy = () => {
if (!data?.analysis) return;
const success = copy(data.analysis);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Popover
open={open}
@@ -71,7 +90,7 @@ export function AnalyzeLogs({ logs, context }: Props) {
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 h-4 w-4" />
<Bot className="mr-2 size-4" />
AI
</Button>
</PopoverTrigger>
@@ -168,6 +187,18 @@ export function AnalyzeLogs({ logs, context }: Props) {
)}
Re-analyze
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
title="Copy analysis to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="sm"
variant="ghost"
@@ -347,11 +347,13 @@ export const DockerLogsId: React.FC<Props> = ({
title={isPaused ? "Resume logs" : "Pause logs"}
>
{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
variant="outline"
@@ -362,11 +364,13 @@ export const DockerLogsId: React.FC<Props> = ({
title="Copy logs to clipboard"
>
{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
variant="outline"
@@ -374,17 +378,18 @@ export const DockerLogsId: React.FC<Props> = ({
className="h-9 sm:w-auto w-full"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
>
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
<DownloadIcon className="size-4" />
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (
<AlertBlock type="warning">
<AlertBlock type="warning" className="items-center">
<div className="flex items-center gap-2">
<Pause className="h-4 w-4" />
<Pause className="size-4" />
<span>
Logs paused
{messageBuffer.length > 0 && (
@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -15,7 +16,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -15,7 +16,6 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
@@ -26,8 +26,8 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
uploadFileToContainerSchema,
type UploadFileToContainer,
uploadFileToContainerSchema,
} from "@/utils/schema";
interface Props {
@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mariadbId: string;
@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mongoId: string;
@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
mysqlId: string;
@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
postgresId: string;
@@ -71,6 +71,9 @@ interface Props {
export const AddApplication = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: servers } = api.server.withSSHKey.useQuery();
@@ -171,7 +174,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
Select a Server{" "}
{showLocalOption ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -191,17 +195,19 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
field.value || (showLocalOption ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
{showLocalOption && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -225,7 +231,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -74,6 +74,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync, isPending, error, isError } =
api.compose.create.useMutation();
@@ -182,7 +185,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
Select a Server{" "}
{showLocalOption ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -202,17 +206,19 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
field.value || (showLocalOption ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
{showLocalOption && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -236,7 +242,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -219,6 +219,9 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const { data: servers } = api.server.withSSHKey.useQuery();
const libsqlMutation = api.libsql.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
@@ -470,19 +473,20 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
field.value ||
(showLocalOption ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
showLocalOption ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
{showLocalOption && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -501,7 +505,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -632,7 +637,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
control={form.control}
name="enableNamespaces"
render={({ field }) => {
console.log(field.value);
return (
<FormItem>
<FormLabel>Enable Namespaces</FormLabel>
@@ -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 {
BookText,
Bookmark,
BookText,
CheckIcon,
ChevronsUpDown,
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>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 ">
@@ -491,7 +491,7 @@ export const ShowProjects = () => {
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<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">
<DateTooltip date={project.createdAt}>
Created
@@ -1,10 +1,10 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { toast } from "sonner";
interface Props {
redisId: string;
@@ -25,7 +25,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { NumberInput } from "@/components/ui/input";
import {
Dialog,
DialogContent,
@@ -34,6 +33,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { NumberInput } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
@@ -49,7 +49,11 @@ export const ShowGitProviders = () => {
api.gitProvider.remove.useMutation();
const { mutateAsync: toggleShare, isPending: isToggling } =
api.gitProvider.toggleShare.useMutation();
const { data: currentMember } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const url = useUrl();
const isOrgAdmin =
currentMember?.role === "owner" || currentMember?.role === "admin";
const getGitlabUrl = (
clientId: string,
@@ -87,18 +91,20 @@ export const ShowGitProviders = () => {
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<GitBranch className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
Create your first Git Provider
No Git Providers configured
</span>
<div>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
{permissions?.gitProviders.create && (
<div>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
</div>
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -106,14 +112,16 @@ export const ShowGitProviders = () => {
<span className="text-base font-medium">
Available Providers
</span>
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
{permissions?.gitProviders.create && (
<div className="flex items-center bg-sidebar p-1 w-full rounded-lg">
<div className="flex flex-wrap items-center gap-4 p-3.5 rounded-lg bg-background border w-full [&>button]:grow">
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-4 rounded-lg ">
@@ -123,17 +131,13 @@ export const ShowGitProviders = () => {
const isBitbucket =
gitProvider.providerType === "bitbucket";
const isGitea = gitProvider.providerType === "gitea";
const canManage = gitProvider.isOwner || isOrgAdmin;
const haveGithubRequirements =
isGithub &&
gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId;
isGithub && gitProvider.github?.isConfigured;
const haveGitlabRequirements =
isGitlab &&
gitProvider.gitlab?.accessToken &&
gitProvider.gitlab?.refreshToken;
isGitlab && gitProvider.gitlab?.isConfigured;
return (
<div
@@ -221,8 +225,7 @@ export const ShowGitProviders = () => {
)}
{isBitbucket &&
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
gitProvider.bitbucket?.isDeprecated ? (
<Badge variant="yellow">Deprecated</Badge>
) : null}
@@ -235,7 +238,7 @@ export const ShowGitProviders = () => {
Action Required
</Badge>
<Link
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github.githubId}`}
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github?.githubId}`}
className={buttonVariants({
size: "icon",
variant: "ghost",
@@ -271,7 +274,7 @@ export const ShowGitProviders = () => {
href={getGitlabUrl(
gitProvider.gitlab?.applicationId || "",
gitProvider.gitlab?.gitlabId || "",
gitProvider.gitlab?.gitlabUrl,
gitProvider.gitlab?.gitlabUrl || "",
)}
target="_blank"
className={buttonVariants({
@@ -284,31 +287,35 @@ export const ShowGitProviders = () => {
</div>
)}
{gitProvider.isOwner && (
{canManage && (
<>
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github?.githubId}
/>
)}
{isGithub &&
haveGithubRequirements &&
gitProvider.github?.githubId && (
<EditGithubProvider
githubId={gitProvider.github.githubId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
/>
)}
{isGitlab &&
gitProvider.gitlab?.gitlabId && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab.gitlabId}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
}
/>
)}
{isBitbucket &&
gitProvider.bitbucket?.bitbucketId && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket.bitbucketId
}
/>
)}
{isGitea && (
{isGitea && gitProvider.gitea?.giteaId && (
<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 { Switch } from "@/components/ui/switch";
import {
@@ -7,8 +9,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
interface Props {
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,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
@@ -53,6 +54,7 @@ const Schema = z.object({
message: "SSH Key is required",
}),
serverType: z.enum(["deploy", "build"]).default("deploy"),
enableDockerCleanup: z.boolean().default(true),
});
type Schema = z.infer<typeof Schema>;
@@ -90,6 +92,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
username: "root",
sshKeyId: "",
serverType: "deploy",
enableDockerCleanup: true,
},
resolver: zodResolver(Schema),
});
@@ -103,6 +106,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
serverType: data?.serverType || "deploy",
enableDockerCleanup: data?.enableDockerCleanup ?? true,
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -119,6 +123,7 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: data.serverType || "deploy",
enableDockerCleanup: data.enableDockerCleanup,
serverId: serverId || "",
})
.then(async (_data) => {
@@ -418,6 +423,27 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="enableDockerCleanup"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>Enable Docker Cleanup</FormLabel>
<FormDescription>
Automatically prune unused Docker images daily. Keeps disk
usage in check on this remote server.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</form>
<DialogFooter>
@@ -131,10 +131,10 @@ export const ShowServers = () => {
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<ServerIcon className="size-5 text-muted-foreground" />
<CardTitle className="text-lg">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
<CardTitle className="text-lg break-words min-w-0">
{server.name}
</CardTitle>
</div>
@@ -145,7 +145,7 @@ export const ShowServers = () => {
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
className="h-8 w-8 shrink-0 p-0"
>
<span className="sr-only">
More options
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -26,7 +27,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { EnterpriseFeatureLocked } from "@/components/proprietary/enterprise-feature-gate";
import { api, type RouterOutputs } from "@/utils/api";
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
@@ -141,14 +141,14 @@ export const WebDomain = () => {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 md:grid-cols-2"
className="grid w-full gap-4 grid-cols-2"
>
<FormField
control={form.control}
name="domain"
render={({ field }) => {
return (
<FormItem>
<FormItem className="col-span-2 md:col-span-1">
<FormLabel>Domain</FormLabel>
<FormControl>
<Input
@@ -168,7 +168,7 @@ export const WebDomain = () => {
name="letsEncryptEmail"
render={({ field }) => {
return (
<FormItem>
<FormItem className="col-span-2 md:col-span-1">
<FormLabel>Let's Encrypt Email</FormLabel>
<FormControl>
<Input
@@ -209,7 +209,7 @@ export const WebDomain = () => {
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
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 {
Card,
CardContent,
@@ -49,8 +51,17 @@ export const WebServer = () => {
</div>
<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}
{webServerSettings?.serverIp && (
<CopyIcon
className="size-3.5 cursor-pointer hover:text-foreground transition-colors"
onClick={() => {
copy(webServerSettings.serverIp ?? "");
toast.success("Copied to clipboard");
}}
/>
)}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}
+14
View File
@@ -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) {
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
undefined,
@@ -933,6 +946,7 @@ export default function Page({ children }: Props) {
} as React.CSSProperties
}
>
<MobileCloser />
<Sidebar collapsible="icon" variant="floating">
<SidebarHeader>
{/* <SidebarMenuButton
@@ -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 {
/** 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 form = useForm<SSOEmailForm>({
@@ -72,7 +77,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
<LogIn className="mr-2 size-4" />
Sign in with SSO
</Button>
{children}
{!enforce && children}
</div>
);
}
@@ -113,13 +118,15 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
</FormItem>
)}
/>
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-muted-foreground hover:underline"
>
Use email and password instead
</button>
{!enforce && (
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-muted-foreground hover:underline"
>
Use email and password instead
</button>
)}
</form>
</Form>
</div>
@@ -342,7 +342,7 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<FolderInput className="size-4 text-muted-foreground" />
<span className="font-medium max-w-[150px] truncate">
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
{currentProject?.name || "Select Project"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -478,7 +478,7 @@ export const AdvanceBreadcrumb = () => {
aria-expanded={environmentOpen}
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
<span className="font-medium max-w-[150px] truncate">
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
{currentEnvironment?.name || "production"}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -533,7 +533,7 @@ export const AdvanceBreadcrumb = () => {
)}
{projectEnvironments && projectEnvironments.length === 1 && (
<p className="text-sm font-normal ml-1">
<p className="text-sm font-normal ml-1 max-w-[50px] md:max-w-[150px] truncate">
{currentEnvironment?.name || "production"}
</p>
)}
@@ -551,7 +551,7 @@ export const AdvanceBreadcrumb = () => {
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
>
{getServiceIcon(currentService.type)}
<span className="font-medium max-w-[150px] truncate">
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
{currentService.name}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
@@ -617,7 +617,7 @@ export const AdvanceBreadcrumb = () => {
<Button
variant="ghost"
size="icon"
className="size-7 ml-1"
className="size-7 ml-1 hidden md:flex"
onClick={() => {
router.push(
`/dashboard/project/${projectId}/environment/${environmentId}`,
@@ -167,7 +167,13 @@ export const CodeEditor = ({
? css()
: language === "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 : [],
language === "yaml"
? autocompletion({
+1
View File
@@ -63,6 +63,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(
buttonVariants({ variant, size, className }),
"flex gap-2",
className,
)}
ref={ref}
{...props}
@@ -0,0 +1 @@
ALTER TABLE "schedule" ADD COLUMN "description" text;
@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ADD COLUMN "remoteServersOnly" boolean DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ADD COLUMN "enforceSSO" boolean DEFAULT false NOT NULL;
@@ -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";
@@ -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
View 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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42
View File
@@ -1163,6 +1163,48 @@
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"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
}
]
}
+1
View File
@@ -28,6 +28,7 @@ try {
"wait-for-postgres": "wait-for-postgres.ts",
"reset-password": "reset-password.ts",
"reset-2fa": "reset-2fa.ts",
"migrate-auth-secret": "scripts/migrate-auth-secret.ts",
},
bundle: true,
platform: "node",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.2",
"version": "v0.29.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -14,6 +14,7 @@
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
"migrate-auth-secret": "node -r dotenv/config dist/migrate-auth-secret.mjs",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
@@ -122,11 +123,11 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.2.0",
"next": "16.2.6",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.0.0",
"node-pty": "1.1.0",
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
+5
View File
@@ -28,6 +28,11 @@ export default async function handler(
res: NextApiResponse,
) {
const signature = req.headers["x-hub-signature-256"];
if (!signature) {
res.status(401).json({ message: "Missing signature header" });
return;
}
const githubBody = req.body;
if (!githubBody?.installation?.id) {
+1 -1
View File
@@ -84,7 +84,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
+1 -1
View File
@@ -24,7 +24,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
+2 -2
View File
@@ -95,7 +95,7 @@ export async function getServerSideProps(
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/dashboard/home",
},
};
@@ -104,7 +104,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
@@ -32,6 +32,7 @@ import { AddAiAssistant } from "@/components/dashboard/project/add-ai-assistant"
import { AddApplication } from "@/components/dashboard/project/add-application";
import { AddCompose } from "@/components/dashboard/project/add-compose";
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 { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
@@ -1091,6 +1092,10 @@ const EnvironmentPage = (
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddImport
projectName={projectData?.name}
environmentId={environmentId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -1099,7 +1104,7 @@ const EnvironmentPage = (
</div>
<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-2">
<Checkbox
@@ -1620,9 +1625,9 @@ const EnvironmentPage = (
<ContextMenuTrigger asChild>
<Link
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 && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
@@ -1827,7 +1832,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
@@ -93,6 +93,7 @@ const Service = (
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
@@ -147,8 +148,9 @@ const Service = (
<Badge
className="cursor-pointer"
onClick={() => {
if (data?.server?.ipAddress) {
copy(data.server.ipAddress);
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
@@ -451,7 +453,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
@@ -85,6 +85,7 @@ const Service = (
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
@@ -134,8 +135,9 @@ const Service = (
<Badge
className="cursor-pointer"
onClick={() => {
if (data?.server?.ipAddress) {
copy(data.server.ipAddress);
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
@@ -455,7 +457,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
@@ -1,5 +1,6 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -9,6 +10,7 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
@@ -61,6 +63,7 @@ const Libsql = (
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
return (
<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-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -307,7 +318,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
@@ -1,5 +1,6 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -9,6 +10,7 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
@@ -63,6 +65,7 @@ const Mariadb = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
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-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -336,7 +347,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
@@ -1,5 +1,6 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -9,6 +10,7 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
@@ -63,6 +65,7 @@ const Mongo = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
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-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -340,7 +351,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};
@@ -1,5 +1,6 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -9,6 +10,7 @@ import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
@@ -62,6 +64,7 @@ const MySql = (
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: serverIp } = api.settings.getIp.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
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-row h-fit w-fit gap-2">
<Badge
className="cursor-pointer"
onClick={() => {
const ip = data?.server?.ipAddress || serverIp;
if (ip) {
copy(ip);
toast.success("IP Address Copied!");
}
}}
variant={
!data?.serverId
? "default"
@@ -318,7 +329,7 @@ export async function getServerSideProps(
if (!user) {
return {
redirect: {
permanent: true,
permanent: false,
destination: "/",
},
};

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