Compare commits

...

133 Commits

Author SHA1 Message Date
Mauricio Siu 439f575669 refactor: unify server admin tools into dashboard pages with server selector (#4625)
* refactor: unify server admin tools into dashboard pages with server selector

Replace the per-server Advanced dropdown (Traefik file system, Docker
containers, swarm overview, swarm nodes, schedules) with a server
selector on the existing dashboard routes, defaulting to the Dokploy
server. Pages are now available in cloud too, since the dropdown was
the only entry point there; the cloud-only monitoring modal moves to
an icon button on the server card.

* feat: add frontend-design skill and enhance dashboard UI components

- Introduced a new skill for creating high-quality frontend designs, emphasizing intentional aesthetics and detailed guidelines for implementation.
- Updated the Traefik system component to improve the user experience when no files or directories are found, incorporating new icons and a more informative layout.
- Enhanced the server filter component with improved loading states, user prompts, and a more visually appealing design, including badges and better server information display.

* [autofix.ci] apply automated fixes

* style: adjust Card component layout in schedules page for improved responsiveness

- Modified the Card component in the schedules page to ensure it utilizes full width while maintaining the minimum height, enhancing the overall layout and user experience.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-12 14:39:08 -06:00
Mauricio Siu 1f4f94042f fix: prevent registry password from appearing in error messages and shell commands (#4579) 2026-06-08 09:20:34 -06:00
Mauricio Siu e9a0932b23 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>
2026-06-07 02:10:49 -06:00
Mauricio Siu 6b68fcab8c 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>
2026-06-07 01:29:04 -06:00
Mauricio Siu dfbae18557 fix: correct deriveCookieSecret test to validate 16-byte hex secret as per oauth2-proxy requirements 2026-06-07 01:25:05 -06:00
Mauricio Siu c1c887d03c fix: update deriveCookieSecret to meet oauth2-proxy requirements 2026-06-07 00:50:20 -06:00
Mauricio Siu 0f77c40ee3 refactor: replace BETTER_AUTH_SECRET with betterAuthSecret in forward-auth setup 2026-06-07 00:28:57 -06:00
Mauricio Siu a0288f83d5 fix: enforce docker:read on container start/stop/kill/restart mutations (#4568) 2026-06-07 00:18:40 -06:00
Mauricio Siu 4900204107 fix: use swarm advertise address in docker swarm join command (#4567) 2026-06-07 00:15:09 -06:00
Mauricio Siu 0f76d8f385 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>
2026-06-07 00:12:08 -06:00
Mauricio Siu c968a2755e 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>
2026-06-06 17:45:24 -06:00
Mauricio Siu f35f3064e9 chore: bump dokploy version to v0.29.8 2026-06-06 15:08:52 -06:00
Mauricio Siu c377be0a14 fix: respect gitProviders permissions in git provider UI (#4561) 2026-06-06 15:08:32 -06:00
Mauricio Siu e944603f99 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
2026-06-06 14:49:24 -06:00
Mauricio Siu e6fc3db08f 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>
2026-06-06 14:21:57 -06:00
Mauricio Siu 57ef96a458 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
2026-06-06 14:05:03 -06:00
Mauricio Siu b29a87aaa8 Merge pull request #4555 from Dokploy/feat/forward-auth-sso
Feat/forward auth sso
2026-06-06 13:58:05 -06:00
Mauricio Siu 705ca54ccc 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.
2026-06-06 13:54:58 -06:00
Mauricio Siu aa545ec71c 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.
2026-06-06 13:53:34 -06:00
Mauricio Siu 51b5af55d0 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.
2026-06-06 13:27:17 -06:00
Mauricio Siu 28673a6166 Merge branch 'canary' into feat/forward-auth-sso 2026-06-06 03:56:40 -06:00
Mauricio Siu f886010acc Delete .github/workflows/pr-quality.yml 2026-06-06 03:56:23 -06:00
Mauricio Siu 238bb2f6f9 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.
2026-06-06 03:55:07 -06:00
Mauricio Siu 1df6774ee8 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.
2026-06-06 03:53:45 -06:00
Mauricio Siu 35f452d25f Merge branch 'canary' into feat/forward-auth-sso 2026-06-06 03:41:27 -06:00
Mauricio Siu 931203a310 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.
2026-06-06 03:40:36 -06:00
Mauricio Siu a3c8b3bd42 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.
2026-06-06 03:38:25 -06:00
Mauricio Siu 4f6e57cc9c 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.
2026-06-06 03:37:31 -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
Mauricio Siu 41c09cd86b 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.
2026-06-02 01:47:50 -06:00
Mauricio Siu 6ff2ca0173 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-05-31 15:29:05 -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
Mauricio Siu d56a17c8ae Merge branch 'main' into canary 2026-05-30 15:24:19 -06:00
youcef zr 85211afd41 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>
2026-05-30 01:42:49 -06:00
Mauricio Siu 9bd44512f0 chore: update version to v0.29.6 in package.json 2026-05-30 01:36:45 -06:00
Philippe Parage ad680ae108 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.
2026-05-30 01:34:21 -06:00
Mauricio Siu d7d642230c fix: use create permission for basic auth delete instead of delete (#4513) 2026-05-30 01:11:42 -06:00
Mauricio Siu 4ba0f71220 fix: grant create and delete SSH key permissions when canAccessToSSHKeys is enabled for members (#4512) 2026-05-30 01:06:45 -06:00
Mauricio Siu 8018027330 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
2026-05-30 01:02:34 -06:00
Jasael 6675aa6f37 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.
2026-05-24 12:05:28 -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 2f43f605f3 chore: update version to v0.29.5 in package.json 2026-05-22 17:20:12 -06:00
Mauricio Siu 103e2f70a8 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)
2026-05-22 17:11:05 -06:00
Mauricio Siu 34d38cf90e fix: enable comment toggle shortcut in env variable editor (#4402) (#4473) 2026-05-22 17:00:58 -06:00
mixelburg f6e6e5cc00 fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
2026-05-22 16:50:40 -06:00
Mauricio Siu b06138b230 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.
2026-05-22 16:46:26 -06:00
Mauricio Siu af8072d7ad 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>
2026-05-22 16:26:34 -06:00
Francis 6e342ee2f2 fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382) 2026-05-13 01:09:47 -06:00
Nahidujjaman Hridoy ef0cf9bd02 fix: responsive layout (#4391)
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-13 01:03:59 -06:00
Volodymyr Kravchuk 8d88a34a64 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>
2026-05-13 01:03:29 -06:00
Mauricio Siu a50f958a6f feat(settings): add copy button to server IP in web server settings (#4397) 2026-05-13 00:54:20 -06:00
Mauricio Siu 1fdbe87d84 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
2026-05-13 00:49:32 -06:00
Mauricio Siu 67278d8783 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
2026-05-13 00:42:29 -06:00
Mauricio Siu aff200f84f 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.
2026-05-13 00:09:47 -06:00
Mauricio Siu 558d809871 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
2026-05-13 00:04:26 -06:00
Mauricio Siu f8fcf68909 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.
2026-05-12 13:26:09 -06:00
Mauricio Siu 7a568aadac Merge pull request #4395 from Dokploy/feat/import-compose-from-base64
feat(compose): add import from base64 in create service dropdown
2026-05-12 13:13:33 -06:00
autofix-ci[bot] 63e33a29cc [autofix.ci] apply automated fixes 2026-05-12 19:12:46 +00:00
Mauricio Siu 754774ea02 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.
2026-05-12 13:12:14 -06:00
Mauricio Siu a714e0f83f Merge pull request #4394 from ngenohkevin/fix/migrate-auth-secret-exit-on-empty
fix(migrate-auth-secret): exit cleanly when there are no 2FA records
2026-05-12 13:04:23 -06:00
ngenohkevin 9f10f0f4e9 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
2026-05-12 21:35:02 +03: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 ccc8f6d047 Merge pull request #4372 from Dokploy/canary
🚀 Release v0.29.3
2026-05-11 11:57:12 -06:00
Mauricio Siu 222b167a76 Merge pull request #4299 from Dokploy/canary
🚀 Release v0.29.2
2026-04-24 22:46:42 -06: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
162 changed files with 46686 additions and 918 deletions
+42
View File
@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
+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
-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 }}"
@@ -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");
@@ -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">
@@ -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;
@@ -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";
@@ -5,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -1,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";
@@ -6,7 +7,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -1,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";
@@ -5,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -1,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";
@@ -5,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -1,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";
@@ -5,7 +6,6 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -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";
@@ -5,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -422,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<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";
@@ -6,7 +7,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -1,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";
@@ -5,7 +6,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -449,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";
@@ -5,7 +6,6 @@ import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
@@ -440,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -90,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>
@@ -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,4 +1,11 @@
import { FileIcon, Folder, Loader2, Workflow } from "lucide-react";
import {
FileIcon,
Folder,
FolderOpen,
Loader2,
MousePointerClick,
Workflow,
} from "lucide-react";
import React from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import {
@@ -68,12 +75,22 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
</div>
)}
{directories?.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
No directories or files detected in{" "}
{"'/etc/dokploy/traefik'"}
</span>
<Folder className="size-8 text-muted-foreground" />
<div className="w-full flex-col gap-4 flex items-center justify-center h-[55vh] border border-dashed rounded-lg">
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
<FolderOpen className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1 text-center px-4">
<span className="text-base font-medium">
No configuration files found
</span>
<span className="text-sm text-muted-foreground">
There are no directories or files in{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
/etc/dokploy/traefik
</code>{" "}
on this server yet.
</span>
</div>
</div>
)}
{directories && directories?.length > 0 && (
@@ -89,11 +106,19 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
{file ? (
<ShowTraefikFile path={file} serverId={serverId} />
) : (
<div className="h-full w-full flex-col gap-2 flex items-center justify-center">
<span className="text-muted-foreground text-lg font-medium">
No file selected
</span>
<FileIcon className="size-8 text-muted-foreground" />
<div className="h-full min-h-[300px] w-full flex-col gap-4 flex items-center justify-center border border-dashed rounded-lg">
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
<MousePointerClick className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1 text-center px-4">
<span className="text-base font-medium">
Select a file to edit
</span>
<span className="text-sm text-muted-foreground">
Choose a file from the tree on the left to view
and edit its contents.
</span>
</div>
</div>
)}
</div>
@@ -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>
@@ -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,30 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowNodes } from "./show-nodes";
interface Props {
serverId: string;
}
export const ShowNodesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Nodes
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="min-w-[70vw]">
<div className="grid w-full gap-1">
<ShowNodes serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};
@@ -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}
/>
)}
@@ -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>
@@ -1,30 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowContainers } from "../../docker/show/show-containers";
interface Props {
serverId: string;
}
export const ShowDockerContainersModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Docker Containers
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="grid w-full gap-1">
<ShowContainers serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};
@@ -1,6 +1,7 @@
import { BarChartHorizontalBigIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-monitoring";
interface Props {
@@ -14,12 +15,9 @@ export const ShowMonitoringModal = ({ url, token }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Monitoring
</DropdownMenuItem>
<Button variant="outline" size="icon" className="h-9 w-9">
<BarChartHorizontalBigIcon className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="flex gap-4 py-4 w-full">
@@ -1,28 +0,0 @@
import { useState } from "react";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
interface Props {
serverId: string;
}
export const ShowSchedulesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Schedules
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl ">
<ShowSchedules id={serverId} scheduleType="server" />
</DialogContent>
</Dialog>
);
};
@@ -4,7 +4,6 @@ import {
Key,
KeyIcon,
Loader2,
MoreHorizontal,
Network,
ServerIcon,
Terminal,
@@ -25,12 +24,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
@@ -38,16 +31,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSchedulesModal } from "./show-schedules-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
export const ShowServers = () => {
@@ -131,59 +119,13 @@ 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>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
More options
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Advanced
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TooltipProvider>
<div className="flex gap-2 mt-2 flex-wrap">
@@ -361,6 +303,27 @@ export const ShowServers = () => {
</Tooltip>
)}
{isCloud &&
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Monitoring</p>
</TooltipContent>
</Tooltip>
)}
<div className="flex-1" />
{permissions?.server.delete && (
@@ -1,48 +0,0 @@
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "../../swarm/monitoring-card";
interface Props {
serverId: string;
}
export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Overview
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid w-full gap-1">
<SwarmMonitorCard serverId={serverId} />
</div>
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers serverId={serverId} />
</div>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};
@@ -1,28 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowTraefikSystem } from "../../file-system/show-traefik-system";
interface Props {
serverId: string;
}
export const ShowTraefikFileSystemModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Traefik File System
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<ShowTraefikSystem serverId={serverId} />
</DialogContent>
</Dialog>
);
};
@@ -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}
+9 -15
View File
@@ -182,36 +182,31 @@ const MENU: Menu = {
title: "Schedules",
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, permissions }) =>
!isCloud && !!permissions?.organization.update,
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
title: "Traefik File System",
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
// Only enabled for users with access to Traefik files in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.traefikFiles.read && !isCloud),
// Only enabled for users with access to Traefik files
isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read,
},
{
isSingle: true,
title: "Docker",
url: "/dashboard/docker",
icon: BlocksIcon,
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Swarm",
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
@@ -375,9 +370,8 @@ const MENU: Menu = {
title: "Cluster",
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.organization.update && !isCloud),
// Only enabled for admins
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
@@ -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>
@@ -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({
@@ -0,0 +1,156 @@
import { Loader2, PlusIcon, ServerIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DOKPLOY_SERVER = "dokploy-server";
interface Props {
children: (serverId?: string) => ReactNode;
}
export const ServerFilter = ({ children }: Props) => {
const router = useRouter();
const { data: servers, isLoading: isLoadingServers } =
api.server.withSSHKey.useQuery();
const { data: isCloud, isLoading: isLoadingCloud } =
api.settings.isCloud.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const queryServerId =
typeof router.query.serverId === "string"
? router.query.serverId
: undefined;
const selectedServer = servers?.find(
(server) => server.serverId === queryServerId,
);
// Cloud has no local Dokploy server, so fall back to the first remote server
const serverId = selectedServer
? selectedServer.serverId
: isCloud
? servers?.[0]?.serverId
: undefined;
const setServerId = (value: string) => {
const { serverId: _current, ...query } = router.query;
router.replace(
{
pathname: router.pathname,
query: value === DOKPLOY_SERVER ? query : { ...query, serverId: value },
},
undefined,
{ shallow: true },
);
};
if (isLoadingServers || isLoadingCloud) {
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md flex flex-col gap-2 items-center justify-center min-h-[60vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
</span>
<Loader2 className="animate-spin size-8 text-muted-foreground" />
</div>
</Card>
);
}
if (isCloud && !servers?.length) {
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md flex flex-col items-center justify-center gap-5 min-h-[60vh] border border-dashed px-4">
<div className="flex items-center justify-center size-16 rounded-full bg-muted">
<ServerIcon className="size-8 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1.5 text-center max-w-md">
<span className="text-lg font-medium">No servers yet</span>
<span className="text-sm text-muted-foreground">
{permissions?.server.create
? "This section works on your remote servers. Add your first server to start managing it from here."
: "This section works on your remote servers. Ask an administrator to add a server to your organization."}
</span>
</div>
{permissions?.server.create && (
<Button asChild>
<Link href="/dashboard/settings/servers">
<PlusIcon className="size-4" />
Add Server
</Link>
</Button>
)}
</div>
</Card>
);
}
return (
<div className="flex flex-col gap-4 w-full">
{!!servers?.length && (
<div className="flex w-full items-center justify-end gap-3">
<Label
htmlFor="server-filter"
className="text-sm text-muted-foreground whitespace-nowrap"
>
Viewing server
</Label>
<Select
value={serverId ?? DOKPLOY_SERVER}
onValueChange={setServerId}
>
<SelectTrigger id="server-filter" className="w-fit min-w-[220px]">
<div className="flex items-center gap-2">
<ServerIcon className="size-4 text-muted-foreground" />
<SelectValue placeholder="Select a server" />
</div>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{!isCloud && (
<SelectItem value={DOKPLOY_SERVER}>
<div className="flex items-center gap-2">
<span>Dokploy Server</span>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0"
>
Local
</Badge>
</div>
</SelectItem>
)}
{servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
<div className="flex items-center gap-2">
<span>{server.name}</span>
<span className="text-xs text-muted-foreground">
{server.ipAddress}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
<Fragment key={serverId ?? DOKPLOY_SERVER}>{children(serverId)}</Fragment>
</div>
);
};
@@ -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
+35
View File
@@ -1170,6 +1170,41 @@
"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
}
]
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.4",
"version": "v0.29.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -123,7 +123,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.2.0",
"next": "16.2.6",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
+6 -10
View File
@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return <ShowContainers />;
return (
<ServerFilter>
{(serverId) => <ShowContainers serverId={serverId} />}
</ServerFilter>
);
};
export default Dashboard;
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
@@ -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>
)}
@@ -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!");
}
}}
@@ -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!");
}
}}
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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 Postgresql = (
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 || "",
});
@@ -109,6 +112,14 @@ const Postgresql = (
<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"
@@ -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 Redis = (
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 || "",
});
@@ -109,6 +112,14 @@ const Redis = (
<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"
+14 -20
View File
@@ -1,25 +1,27 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
function SchedulesPage() {
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules
scheduleType="dokploy-server"
id={user?.user.id || ""}
/>
<ServerFilter>
{(serverId) => (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</div>
</Card>
</div>
</Card>
</div>
)}
</ServerFilter>
);
}
export default SchedulesPage;
@@ -31,14 +33,6 @@ SchedulesPage.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || (user.role !== "owner" && user.role !== "admin")) {
return {
@@ -1,17 +1,22 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowNodes />
</div>
<ServerFilter>
{(serverId) => (
<div className="flex flex-col gap-4 w-full">
<ShowNodes serverId={serverId} />
</div>
)}
</ServerFilter>
);
};
@@ -24,14 +29,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user || user.role === "member") {
return {
+58 -3
View File
@@ -1,15 +1,28 @@
import { validateRequest } from "@dokploy/server";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ToggleEnforceSSO } from "@/components/dashboard/settings/servers/actions/toggle-enforce-sso";
import { ToggleRemoteServersOnly } from "@/components/dashboard/settings/servers/actions/toggle-remote-servers-only";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { ForwardAuthServers } from "@/components/proprietary/sso/forward-auth-servers";
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import { Card } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
const Page = () => {
interface Props {
isCloud: boolean;
}
const Page = ({ isCloud }: Props) => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
@@ -29,6 +42,47 @@ const Page = () => {
</div>
</div>
</Card>
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
lockedProps={{
title: "Application Authentication",
description:
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
ctaLabel: "Go to License",
}}
>
<ForwardAuthServers />
</EnterpriseFeatureGate>
</div>
</Card>
{!isCloud && (
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<EnterpriseFeatureGate
lockedProps={{
title: "Self-hosted Restrictions",
description:
"Deployment and authentication restrictions are part of Dokploy Enterprise. Add a valid license to configure them.",
ctaLabel: "Go to License",
}}
>
<CardHeader>
<CardTitle className="text-xl">
Self-hosted Restrictions
</CardTitle>
<CardDescription>
Control deployment targets and authentication behavior.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<ToggleRemoteServersOnly />
<ToggleEnforceSSO />
</CardContent>
</EnterpriseFeatureGate>
</div>
</Card>
)}
</div>
</div>
);
@@ -76,6 +130,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
},
};
}
+23 -27
View File
@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
@@ -7,30 +6,35 @@ import superjson from "superjson";
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return (
<div className="space-y-4">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<SwarmMonitorCard />
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
<ServerFilter>
{(serverId) => (
<div className="space-y-4">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<SwarmMonitorCard serverId={serverId} />
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers serverId={serverId} />
</div>
</Card>
</TabsContent>
</Tabs>
</div>
)}
</ServerFilter>
);
};
@@ -42,14 +46,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
+6 -10
View File
@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowTraefikSystem } from "@/components/dashboard/file-system/show-traefik-system";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return <ShowTraefikSystem />;
return (
<ServerFilter>
{(serverId) => <ShowTraefikSystem serverId={serverId} />}
</ServerFilter>
);
};
export default Dashboard;
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
+14 -3
View File
@@ -1,4 +1,8 @@
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
import {
getWebServerSettings,
IS_CLOUD,
isAdminPresent,
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { REGEXP_ONLY_DIGITS } from "input-otp";
@@ -52,8 +56,9 @@ type LoginForm = z.infer<typeof LoginSchema>;
interface Props {
IS_CLOUD: boolean;
enforceSSO: boolean;
}
export default function Home({ IS_CLOUD }: Props) {
export default function Home({ IS_CLOUD, enforceSSO }: Props) {
const router = useRouter();
const { config: whitelabeling } = useWhitelabelingPublic();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
@@ -247,7 +252,9 @@ export default function Home({ IS_CLOUD }: Props) {
<CardContent className="p-0">
{!isTwoFactor ? (
<>
{showSignInWithSSO ? (
{enforceSSO ? (
<SignInWithSSO enforce />
) : showSignInWithSSO ? (
<SignInWithSSO>{loginContent}</SignInWithSSO>
) : (
loginContent
@@ -417,6 +424,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
props: {
IS_CLOUD: IS_CLOUD,
enforceSSO: false,
},
};
}
@@ -442,9 +450,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const webServerSettings = await getWebServerSettings();
return {
props: {
hasAdmin,
enforceSSO: webServerSettings?.enforceSSO ?? false,
},
};
}
+1 -1
View File
@@ -46,7 +46,7 @@ async function main() {
if (records.length === 0) {
console.log("✅ No 2FA records found, nothing to migrate.");
return;
process.exit(0);
}
console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`);
+2
View File
@@ -30,6 +30,7 @@ import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -93,6 +94,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
forwardAuth: forwardAuthRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,
+12 -9
View File
@@ -4,11 +4,11 @@ import {
deleteAllMiddlewares,
findApplicationById,
findEnvironmentById,
findGitProviderById,
findProjectById,
getAccessibleServerIds,
getApplicationStats,
getContainerLogs,
getWebServerSettings,
IS_CLOUD,
mechanizeDockerContainer,
readConfig,
@@ -30,6 +30,7 @@ import {
writeConfigRemote,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -87,7 +88,11 @@ export const applicationRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create an application",
@@ -169,13 +174,11 @@ export const applicationRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
} catch {
const canEdit = await canEditDeployGitSource(
gitProviderId,
ctx.session,
);
if (!canEdit) {
hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType;
}
+8 -4
View File
@@ -96,9 +96,11 @@ export const clusterRouter = createTRPCRouter({
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();
const info = await docker.info();
let ip = await getLocalServerIp();
if (input.serverId) {
const swarmNodeAddr = info?.Swarm?.NodeAddr;
let ip = swarmNodeAddr || (await getLocalServerIp());
if (!swarmNodeAddr && input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}
@@ -128,9 +130,11 @@ export const clusterRouter = createTRPCRouter({
const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect();
const docker_version = await docker.version();
const info = await docker.info();
let ip = await getLocalServerIp();
if (input.serverId) {
const swarmNodeAddr = info?.Swarm?.NodeAddr;
let ip = swarmNodeAddr || (await getLocalServerIp());
if (!swarmNodeAddr && input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}
+86 -10
View File
@@ -13,7 +13,6 @@ import {
findComposeById,
findDomainsByComposeId,
findEnvironmentById,
findGitProviderById,
findProjectById,
findServerById,
getAccessibleServerIds,
@@ -34,6 +33,7 @@ import {
updateDeploymentStatus,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
addNewService,
checkServiceAccess,
@@ -91,7 +91,11 @@ export const composeRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a compose",
@@ -169,13 +173,11 @@ export const composeRouter = createTRPCRouter({
const gitProviderId = getGitProviderId();
if (gitProviderId) {
try {
const gitProvider = await findGitProviderById(gitProviderId);
if (gitProvider.userId !== ctx.session.userId) {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
} catch {
const canEdit = await canEditDeployGitSource(
gitProviderId,
ctx.session,
);
if (!canEdit) {
hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType;
}
@@ -585,7 +587,11 @@ export const composeRouter = createTRPCRouter({
await checkServiceAccess(ctx, environment.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a compose",
@@ -862,6 +868,76 @@ export const composeRouter = createTRPCRouter({
}
}),
previewTemplate: protectedProcedure
.input(
z.object({
base64: z.string(),
appName: z.string(),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
if (input.serverId) {
const accessibleIds = await getAccessibleServerIds(ctx.session);
if (!accessibleIds.has(input.serverId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
let serverIp = "127.0.0.1";
if (input.serverId) {
const server = await findServerById(input.serverId);
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV !== "development") {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const templateData = JSON.parse(decodedData);
const config = parse(templateData.config) as CompleteTemplate;
if (!templateData.compose || !config) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Invalid template format. Must contain compose and config fields",
});
}
const configModified = {
...config,
variables: {
APP_NAME: input.appName,
...config.variables,
},
};
const processedTemplate = processTemplate(configModified, {
serverIp,
projectName: input.appName,
});
return {
compose: templateData.compose,
template: processedTemplate,
};
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error processing template: ${error instanceof Error ? error.message : error}`,
});
}
}),
import: protectedProcedure
.input(
z.object({
@@ -151,6 +151,14 @@ export const deploymentRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
} else if (deployment.schedule?.serverId) {
const targetServer = await findServerById(deployment.schedule.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this deployment.",
});
}
}
if (!deployment.pid) {
@@ -188,6 +196,14 @@ export const deploymentRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
} else if (deployment.schedule?.serverId) {
const targetServer = await findServerById(deployment.schedule.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this deployment.",
});
}
}
const result = await removeDeployment(input.deploymentId);
await audit(ctx, {
@@ -197,4 +213,47 @@ export const deploymentRouter = createTRPCRouter({
});
return result;
}),
readLogs: protectedProcedure
.input(
z.object({
deploymentId: z.string().min(1),
tail: z.number().int().min(1).max(10000).default(100),
}),
)
.query(async ({ input, ctx }) => {
const deployment = await findDeploymentById(input.deploymentId);
const serviceId = deployment.applicationId || deployment.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["read"],
});
} else if (deployment.schedule?.serverId) {
const targetServer = await findServerById(deployment.schedule.serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this deployment.",
});
}
}
if (!deployment.logPath) {
return "";
}
const command = `tail -n ${input.tail} "${deployment.logPath}" 2>/dev/null || echo ""`;
const serverId = deployment.serverId || deployment.schedule?.serverId;
if (serverId) {
const { stdout } = await execAsyncRemote(serverId, command);
return stdout;
}
if (IS_CLOUD) {
return "";
}
const { stdout } = await execAsync(command);
return stdout;
}),
});
+4 -4
View File
@@ -38,7 +38,7 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
restartContainer: withPermission("service", "read")
restartContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -64,7 +64,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
startContainer: withPermission("service", "read")
startContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -90,7 +90,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
stopContainer: withPermission("service", "read")
stopContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -116,7 +116,7 @@ export const dockerRouter = createTRPCRouter({
});
}),
killContainer: withPermission("service", "read")
killContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
@@ -42,6 +42,43 @@ export const gitProviderRouter = createTRPCRouter({
return results.map((r) => ({
...r,
isOwner: r.userId === ctx.session.userId,
github: r.github
? {
githubId: r.github.githubId,
githubAppName: r.github.githubAppName,
githubAppId: r.github.githubAppId,
githubInstallationId: r.github.githubInstallationId,
isConfigured: !!(
r.github.githubPrivateKey &&
r.github.githubAppId &&
r.github.githubInstallationId
),
}
: null,
gitlab: r.gitlab
? {
gitlabId: r.gitlab.gitlabId,
applicationId: r.gitlab.applicationId,
gitlabUrl: r.gitlab.gitlabUrl,
isConfigured: !!(r.gitlab.accessToken && r.gitlab.refreshToken),
}
: null,
bitbucket: r.bitbucket
? {
bitbucketId: r.bitbucket.bitbucketId,
bitbucketUsername: r.bitbucket.bitbucketUsername,
isConfigured: false,
isDeprecated: !!(r.bitbucket.appPassword && !r.bitbucket.apiToken),
}
: null,
gitea: r.gitea
? {
giteaId: r.gitea.giteaId,
giteaUrl: r.gitea.giteaUrl,
clientId: r.gitea.clientId,
isConfigured: !!(r.gitea.accessToken && r.gitea.refreshToken),
}
: null,
}));
}),
+6 -1
View File
@@ -8,6 +8,7 @@ import {
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeLibsqlById,
@@ -51,7 +52,11 @@ export const libsqlRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Libsql",
+6 -1
View File
@@ -12,6 +12,7 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeMariadbById,
@@ -62,7 +63,11 @@ export const mariadbRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Mariadb",
+6 -1
View File
@@ -12,6 +12,7 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeMongoById,
@@ -61,7 +62,11 @@ export const mongoRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a mongo",
+6 -1
View File
@@ -12,6 +12,7 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeMySqlById,
@@ -62,7 +63,11 @@ export const mysqlRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a MySQL",
@@ -295,6 +295,14 @@ export const organizationRouter = createTRPCRouter({
});
}
// Owner role is non-delegable — no one can invite as owner
if (input.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot invite a user with the owner role",
});
}
// If assigning a custom role, verify it exists
if (!["owner", "admin", "member"].includes(input.role)) {
const customRole = await db.query.organizationRole.findFirst({
+6 -1
View File
@@ -13,6 +13,7 @@ import {
getContainerLogs,
getMountPath,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removePostgresById,
@@ -63,7 +64,11 @@ export const postgresRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Postgres",
@@ -0,0 +1,207 @@
import {
assertApplicationDomainAccess,
deployForwardAuthOnServer,
disableForwardAuthOnDomain,
enableForwardAuthOnDomain,
findServerById,
forwardAuthCallbackUrl,
getDomainSsoStatus,
getForwardAuthServerStatus,
getForwardAuthSettings,
listSsoProvidersForOrg,
removeForwardAuthProxy,
removeForwardAuthSettings,
setForwardAuthSettings,
} from "@dokploy/server";
import {
apiDeployForwardAuthOnServer,
apiForwardAuthDomainTarget,
apiForwardAuthServerTarget,
apiSetForwardAuthSettings,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import {
createTRPCRouter,
enterpriseProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
export const forwardAuthRouter = createTRPCRouter({
getAuthDomain: enterpriseProcedure
.input(apiForwardAuthServerTarget)
.query(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const settings = await getForwardAuthSettings(input.serverId);
if (!settings) return null;
return {
host: settings.authDomain,
https: settings.https,
certificateType: settings.certificateType,
customCertResolver: settings.customCertResolver,
callbackUrl: forwardAuthCallbackUrl(
settings.authDomain,
settings.https,
),
};
}),
setAuthDomain: enterpriseProcedure
.input(apiSetForwardAuthSettings)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await setForwardAuthSettings({
organizationId: ctx.session.activeOrganizationId,
serverId: input.serverId,
authDomain: input.authDomain,
https: input.https,
certificateType: input.certificateType,
customCertResolver: input.customCertResolver,
});
await audit(ctx, {
action: "update",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth-domain",
});
return result;
}),
removeAuthDomain: enterpriseProcedure
.input(apiForwardAuthServerTarget)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await removeForwardAuthSettings(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth-domain",
});
return result;
}),
listProviders: enterpriseProcedure.query(({ ctx }) =>
listSsoProvidersForOrg(ctx.session.activeOrganizationId),
),
serverStatus: enterpriseProcedure.query(({ ctx }) =>
getForwardAuthServerStatus(ctx.session.activeOrganizationId),
),
deployOnServer: enterpriseProcedure
.input(apiDeployForwardAuthOnServer)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await deployForwardAuthOnServer({
serverId: input.serverId ?? undefined,
providerId: input.providerId,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "create",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth",
});
return result;
}),
removeOnServer: enterpriseProcedure
.input(apiForwardAuthServerTarget)
.mutation(async ({ ctx, input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const result = await removeForwardAuthProxy(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "server",
resourceId: input.serverId ?? "local",
resourceName: "forward-auth",
});
return result;
}),
status: withPermission("domain", "read")
.input(apiForwardAuthDomainTarget)
.query(({ ctx, input }) => getDomainSsoStatus(ctx, input.domainId)),
enable: withPermission("domain", "create")
.input(apiForwardAuthDomainTarget)
.mutation(async ({ ctx, input }) => {
const domain = await assertApplicationDomainAccess(
ctx,
input.domainId,
"create",
);
const result = await enableForwardAuthOnDomain({
domainId: input.domainId,
});
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
return result;
}),
disable: withPermission("domain", "create")
.input(apiForwardAuthDomainTarget)
.mutation(async ({ ctx, input }) => {
const domain = await assertApplicationDomainAccess(
ctx,
input.domainId,
"create",
);
const result = await disableForwardAuthOnDomain({
domainId: input.domainId,
});
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
return result;
}),
});
@@ -8,6 +8,7 @@ import {
requestToHeaders,
} from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
@@ -43,12 +44,16 @@ export const ssoRouter = createTRPCRouter({
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
);
}),
enforceSSO: publicProcedure.query(async () => {
if (IS_CLOUD) {
return false;
}
const settings = await getWebServerSettings();
return settings?.enforceSSO ?? false;
}),
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.ssoProvider.findMany({
where: and(
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: {
id: true,
providerId: true,
@@ -80,7 +85,6 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -108,12 +112,12 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
userId: true,
},
});
@@ -125,6 +129,13 @@ export const ssoRouter = createTRPCRouter({
});
}
if (existing.userId !== ctx.session.userId) {
await db
.update(ssoProvider)
.set({ userId: ctx.session.userId })
.where(eq(ssoProvider.id, existing.id));
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
@@ -210,7 +221,6 @@ export const ssoRouter = createTRPCRouter({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
@@ -233,7 +243,6 @@ export const ssoRouter = createTRPCRouter({
and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
)
.returning({ id: ssoProvider.id });
+6 -1
View File
@@ -11,6 +11,7 @@ import {
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
getWebServerSettings,
IS_CLOUD,
rebuildDatabase,
removeRedisById,
@@ -59,7 +60,11 @@ export const redisRouter = createTRPCRouter({
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
const webServerSettings = await getWebServerSettings();
if (
(IS_CLOUD || webServerSettings?.remoteServersOnly) &&
!input.serverId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to use a server to create a Redis",
+21 -54
View File
@@ -75,7 +75,12 @@ export const scheduleRouter = createTRPCRouter({
}
}
}
const newSchedule = await createSchedule(input);
const newSchedule = await createSchedule({
...input,
...(input.scheduleType === "dokploy-server" && {
organizationId: ctx.session.activeOrganizationId,
}),
});
if (newSchedule?.enabled) {
if (IS_CLOUD) {
@@ -162,17 +167,6 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
existingSchedule.scheduleType === "dokploy-server" &&
existingSchedule.userId &&
existingSchedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
const updatedSchedule = await updateSchedule(input);
@@ -256,17 +250,6 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
await deleteSchedule(input.scheduleId);
@@ -323,21 +306,27 @@ export const scheduleRouter = createTRPCRouter({
}
}
if (
input.scheduleType === "dokploy-server" &&
input.id !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only list your own host-level schedules.",
});
if (input.scheduleType === "dokploy-server") {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can list host-level schedules.",
});
}
}
}
const where = {
application: eq(schedules.applicationId, input.id),
compose: eq(schedules.composeId, input.id),
server: eq(schedules.serverId, input.id),
"dokploy-server": eq(schedules.userId, input.id),
"dokploy-server": eq(
schedules.organizationId,
ctx.session.activeOrganizationId,
),
};
return db.query.schedules.findMany({
where: where[input.scheduleType],
@@ -376,17 +365,6 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
schedule.scheduleType === "dokploy-server" &&
schedule.userId &&
schedule.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this schedule.",
});
}
}
return schedule;
}),
@@ -439,17 +417,6 @@ export const scheduleRouter = createTRPCRouter({
});
}
}
if (
scheduleItem.scheduleType === "dokploy-server" &&
scheduleItem.userId &&
scheduleItem.userId !== ctx.user.id
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can only manage your own host-level schedules.",
});
}
}
try {
await runCommand(input.scheduleId);
+1 -1
View File
@@ -45,7 +45,7 @@ export const securityRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => {
const security = await findSecurityById(input.securityId);
await checkServicePermissionAndAccess(ctx, security.applicationId, {
service: ["delete"],
service: ["create"],
});
const result = await deleteSecurityById(input.securityId);
await audit(ctx, {

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