Compare commits

..

125 Commits

Author SHA1 Message Date
Mauricio Siu
1d266b0840 feat(whitelabel): implement whitelabeling features and settings
- Added whitelabeling support, allowing customization of application name, logos, and login page.
- Introduced new WhitelabelSettings component for managing whitelabel configurations.
- Updated onboarding and sidebar layouts to reflect whitelabel settings dynamically.
- Created database schema changes to accommodate new whitelabel fields.
- Implemented API endpoints for retrieving and updating whitelabel settings.
2026-01-31 05:29:41 -06:00
Mauricio Siu
54229b0dcd Merge branch 'canary' into feat/introduce-license-key-pay 2026-01-31 05:16:27 -06:00
Mauricio Siu
6b42c9d142 feat(auth): expand disabled paths for SSO registration and organization management
- Added new disabled paths for organization creation, update, and deletion to enhance security in the authentication flow.
2026-01-31 05:11:45 -06:00
Mauricio Siu
7665b38b79 feat(sso): refine provider query to include user ID for enhanced security
- Updated the `listProviders` query to filter SSO providers by both organization ID and user ID.
- Modified the provider validation logic to ensure that only relevant providers are returned for the authenticated user.
2026-01-31 04:46:57 -06:00
Mauricio Siu
d5de5b8ad7 feat(sso): implement SSO provider registration and update related components
- Refactored SSO registration logic in `register-oidc-dialog` and `register-saml-dialog` to use a new mutation method.
- Removed unused imports and error handling for registration failures.
- Added foreign key constraint for `organization_id` in the `sso_provider` table.
- Introduced new SSO schema and updated user relations to include SSO providers.
- Enhanced authentication flow to support SSO provider registration.
2026-01-31 04:43:47 -06:00
Mauricio Siu
fa201a5a96 Update package.json 2026-01-31 04:35:39 -06:00
Mauricio Siu
d22d96105c feat(auth): add SSO request handling and provider validation in authentication flow 2026-01-31 03:50:54 -06:00
Mauricio Siu
bc5c65b2d2 Merge branch 'canary' into feat/introduce-license-key-pay 2026-01-31 03:44:31 -06:00
Mauricio Siu
431ad914f8 Merge pull request #3568 from Dokploy/copilot/fix-swarm-settings-test-commands
Fix swarm health check test commands not persisting
2026-01-31 03:21:20 -06:00
Mauricio Siu
0575fabb0f Merge branch 'canary' into copilot/fix-swarm-settings-test-commands 2026-01-31 03:19:29 -06:00
Mauricio Siu
385a494c83 Merge pull request #3556 from vtomasr5/fix-saving-swarm-settings-placement-preferences
fix: Save Placement button not working for Preferences in Swarm settings
2026-01-31 03:18:41 -06:00
copilot-swe-agent[bot]
d3f0bf654b Fix TypeScript type annotations in health check form
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-01-31 09:16:49 +00:00
copilot-swe-agent[bot]
9e8dacfe06 Fix health check form to properly sync test commands with form state
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-01-31 09:14:40 +00:00
copilot-swe-agent[bot]
f450b13dc5 Initial plan 2026-01-31 09:10:37 +00:00
Mauricio Siu
9e80bf45d0 Merge pull request #3567 from Dokploy/fix/security-GHSA-wmqj-wr9q-327p
feat(schema): enhance appName validation across database schemas with…
2026-01-31 03:06:56 -06:00
Mauricio Siu
a635908e43 fix(mariadb): correct appName validation to use built appName for uniqueness check 2026-01-31 03:05:08 -06:00
Mauricio Siu
960892fd8d feat(schema): enhance appName validation across database schemas with regex and message 2026-01-31 03:01:49 -06:00
Mauricio Siu
acb3c1d238 Add Sign-In Options for Cloud Users: Integrate GitHub and Google sign-in components into the registration page, allowing cloud users to register using these methods. Update UI to present alternative registration options, enhancing user experience. 2026-01-31 01:23:30 -06:00
Mauricio Siu
68587c3c8b Add SSO Provider Integration: Introduce getSSOProviders function to fetch SSO provider details from the database. Update authentication logic to include SSO domains in the server settings, enhancing SSO functionality and user experience. 2026-01-31 01:04:22 -06:00
Mauricio Siu
cae7a92599 Refactor SSO Registration Dialogs: Update RegisterOidcDialog and RegisterSamlDialog components to use field arrays for managing multiple domains and scopes. Enhance validation logic to ensure at least one domain is provided. Improve UI for adding and removing domains and scopes dynamically, streamlining the user experience in SSO configuration. 2026-01-31 00:55:09 -06:00
Mauricio Siu
f3d9960b7f Implement SSO Sign-In Options: Add components for signing in with GitHub, Google, and SSO, enhancing user authentication methods. Update SSO settings to conditionally render based on enterprise features and improve the overall login experience on the homepage. 2026-01-30 22:28:17 -06:00
Mauricio Siu
66b4bf2c4e Comment out user, session, account, verification, and apikey table definitions in auth-schema2.ts for future refactoring and cleanup. 2026-01-30 20:38:13 -06:00
Mauricio Siu
c4515a2ca4 Fix admin creation check in authentication logic: Re-enable the check for existing admin presence before creating a new admin, ensuring proper error handling for duplicate admin creation. Update cloud condition to account for admin presence. 2026-01-30 20:37:39 -06:00
autofix-ci[bot]
1f33b0fd24 [autofix.ci] apply automated fixes 2026-01-31 02:35:36 +00:00
Mauricio Siu
3c2f675eb9 Enhance SSO Functionality: Add detailed view for SSO providers in SSOSettings, including OIDC and SAML configuration parsing. Implement loading states for SSO sign-in on the homepage and expose a public API for listing SSO providers. Update UI components for better user experience and maintainability. 2026-01-30 20:35:17 -06:00
autofix-ci[bot]
61f6bbfe1c [autofix.ci] apply automated fixes 2026-01-31 02:34:32 +00:00
Vicens Juan Tomas Monserrat
8caae549b2 fix(swarm): resolve Save Placement button not working for Preferences
The button was unresponsive because the form's flat data structure
  ({ SpreadDescriptor }) didn't match the Zod schema's nested structure
  ({ Spread: { SpreadDescriptor } }), causing silent validation failure.

  Updated schema to match form state and transform to nested structure
  only when submitting to the API.
2026-01-30 11:48:34 +01:00
Mauricio Siu
30c3e44422 Refactor SSO Registration Dialogs: Remove onSuccess prop from RegisterOidcDialog and RegisterSamlDialog components, replacing it with a call to invalidate the list of SSO providers after successful registration. Update SSOSettings to reflect these changes, enhancing the overall state management and consistency across the dialogs. 2026-01-29 22:56:25 -06:00
Mauricio Siu
f72bc28d70 Refactor enterprise backup cron job initialization: Simplified the cron job setup by consolidating user retrieval and validation logic into a single scheduled job. Updated the schedule to run every 3 days and removed redundant checks for user length. 2026-01-29 22:54:52 -06:00
Mauricio Siu
82c06a487a Remove refresh-license-validity API endpoint and integrate enterprise backup cron job initialization: Deleted the cron endpoint for refreshing license validity and added the initialization of enterprise backup cron jobs in the server setup. Updated the enterprise cron job logic to filter users based on license key and enterprise feature status. 2026-01-29 22:42:59 -06:00
Mauricio Siu
12a87f9f8b Enhance License Key Management and Enterprise Features: Update license key validation logic to ensure proper handling of enterprise licenses, including new cron job for refreshing license validity. Introduce new SQL migration for isValidEnterpriseLicense column and refactor related API procedures for better error handling and user feedback. 2026-01-29 22:37:10 -06:00
Mauricio Siu
9a8de9ae16 Add Enterprise Feature Gate Component: Introduce EnterpriseFeatureGate and EnterpriseFeatureLocked components to manage access to enterprise features based on license validation. Integrate the EnterpriseFeatureGate into the SSO settings page to conditionally render SSOSettings based on license status. 2026-01-29 22:16:23 -06:00
Mauricio Siu
6064b8ca48 Implement SAML Provider Registration and Enhance OIDC Dialog: Add a new SAML provider registration dialog with form validation using Zod, integrate it into the SSO settings page, and refactor the OIDC registration dialog to utilize React Hook Form for improved state management and validation. 2026-01-29 22:11:09 -06:00
Mauricio Siu
7f27601f7f Implement Single Sign-On (SSO) Feature: Add SSO settings page, integrate OIDC provider registration dialog, and update dependencies for better-auth to version 1.4.18. Enhance user interface with new SSO menu item and improve database schema for SSO providers. 2026-01-29 22:01:48 -06:00
Mauricio Siu
2e7f4dc1a2 Refactor License Key Settings UI: Simplify conditional rendering for license key management, update contact link to the official site, and enhance user feedback with improved loading states for activation and validation processes. 2026-01-29 08:14:35 -06:00
Mauricio Siu
2b52332e43 Enhance License Key Management: Add loading state for license key validation, implement query to check for valid license keys, and improve UI feedback during license key checks. 2026-01-29 07:58:50 -06:00
Mauricio Siu
346216fc71 Add License Settings Page: Introduce a new License settings page with server-side validation and layout integration, and update the sidebar menu to include a link for accessing the License settings. 2026-01-28 23:35:25 -06:00
Mauricio Siu
c9ffb99808 Refactor license key deactivation process: update API to retrieve the current user's license key and improve error handling for user validation and missing license keys. 2026-01-28 23:32:04 -06:00
Mauricio Siu
cbfa690a80 Improve error handling in license key management: update error logging to provide more informative messages for validation, activation, and deactivation processes. 2026-01-28 23:30:48 -06:00
Mauricio Siu
262960a59a Refactor license key management: remove legacy license key settings component, enhance license key validation and activation in the API, and implement new methods for activating and deactivating license keys. 2026-01-28 23:26:04 -06:00
Mauricio Siu
709ffddd4f Update better-auth dependency to version 1.2.8 and enhance license key validation in the API to require at least one of enableEnterpriseFeatures or licenseKey. 2026-01-28 22:50:10 -06:00
Mauricio Siu
0c299a3807 Refactor license key management: update API calls to use licenseKey router and clean up organization router by removing enterprise settings methods 2026-01-28 22:39:35 -06:00
Mauricio Siu
25fa362cdb Add enterprise features management: implement license key settings and update user schema 2026-01-28 22:34:17 -06:00
Mauricio Siu
f680818b56 Add enterprise features management: implement license key settings and update user schema 2026-01-28 11:03:00 -06:00
Mauricio Siu
20226a300c Merge pull request #3256 from luojiyin1987/fix/dockerfile-cmd-format
Fix/dockerfile cmd format
2026-01-28 09:57:07 -06:00
Mauricio Siu
5f5c4f0e18 Merge branch 'canary' into fix/dockerfile-cmd-format 2026-01-28 09:55:56 -06:00
Mauricio Siu
c579dbeb1c Merge pull request #3540 from Dokploy/3491-ssl-certificate-issuance-broken-with-inwx
chore(traefik): update Traefik version to 3.6.7 in setup scripts
2026-01-28 00:18:17 -06:00
Mauricio Siu
cee1dc97ba chore(traefik): update Traefik version to 3.6.7 in setup scripts 2026-01-28 00:16:06 -06:00
Mauricio Siu
b9419ed5f1 Merge pull request #3539 from Dokploy/3493-when-adding-a-git-repository-as-a-provider-spaces-in-the-repo-name-break-the-repo-selection
feat(bitbucket): add optional slug field for repositories and update …
2026-01-28 00:14:21 -06:00
Mauricio Siu
6bc07d7675 feat(drop): add optional bitbucketRepositorySlug field to baseApp configuration in tests 2026-01-28 00:12:42 -06:00
autofix-ci[bot]
f72dfb3fc7 [autofix.ci] apply automated fixes 2026-01-28 06:10:38 +00:00
Mauricio Siu
27a0490536 feat(bitbucket): add optional slug field for repositories and update related logic 2026-01-28 00:09:56 -06:00
Mauricio Siu
ec6849205a Merge pull request #3537 from Dokploy/3510-commit-message-is-wrong-when-using-remote-builder
fix(application): update commit info extraction to include appName an…
2026-01-27 21:47:19 -06:00
Mauricio Siu
9934346d8c fix(application): update commit info extraction to include appName and serverId 2026-01-27 21:46:54 -06:00
Mauricio Siu
5c89973cc2 Merge pull request #3385 from stripsior/chore/bump-postgres
chore(databases): bump default postgres version while creating to 18
2026-01-27 21:18:50 -06:00
Mauricio Siu
4e8cdfbc80 Merge pull request #3447 from pluisol/feature/pushover-notifications
feat: add Pushover notification provider
2026-01-27 21:16:36 -06:00
Mauricio Siu
d0ea8b5283 Merge pull request #3504 from Bima42/fix/3503-changing-server-domain-fail-with-only-mail
fix: zod object for assign domain
2026-01-27 13:41:05 -06:00
Mauricio Siu
060a053fdb Merge pull request #3527 from p8008d/fix/profile-firstname-update
fix: profile firstName field not updating
2026-01-27 13:39:32 -06:00
Mauricio Siu
304069d3c8 Merge pull request #3530 from Dokploy/fix/prevent-send-malicious-bash
feat(wss): add directory validation for WebSocket server log paths
2026-01-27 09:57:11 -06:00
Mauricio Siu
5967f48c6b feat(wss): add directory validation for WebSocket server log paths 2026-01-27 09:56:28 -06:00
Mauricio Siu
f3bb56910a Merge pull request #3529 from Dokploy/fix/prevent-send-malicious-bash
fix(wss): add container ID validation to enhance security in WebSocke…
2026-01-27 09:21:06 -06:00
Mauricio Siu
24c1c2a377 fix(wss): add container ID validation to enhance security in WebSocket server 2026-01-27 09:20:29 -06:00
Mauricio Siu
6fdb2e4a21 Merge pull request #3528 from Dokploy/fix/prevent-send-malicious-bash
Fix/prevent send malicious bash
2026-01-27 09:00:11 -06:00
Mauricio Siu
15e90e9ca9 refactor(wss): simplify container ID validation and update Docker command structure 2026-01-27 08:59:58 -06:00
Mauricio Siu
d1553e1bda fix(wss): add cloud version restriction message in command execution 2026-01-27 08:40:57 -06:00
Mauricio Siu
880a377e54 fix(wss): handle cloud version restriction in terminal setup 2026-01-27 08:38:14 -06:00
Mauricio Siu
74e0bd5fe3 fix(wss): update Docker command execution in terminal setup 2026-01-27 08:37:06 -06:00
p8008d
74aecf6828 fix: profile firstName field not updating
The profile form was sending `name` field but the database column is
`firstName`. This caused the firstName to be silently ignored during
updates. Changed form field and API schema to use `firstName` to match
the database column.
2026-01-27 15:07:56 +02:00
Mauricio Siu
7362cc49d2 fix: prevent to pass invalid docker container names 2026-01-26 16:37:15 +02:00
Mauricio Siu
84fa805acc refactor(side): remove Sponsor menu item and associated HeartIcon component 2026-01-25 17:53:16 +02:00
Bima42
bcbf433607 fix: zod object for assign domain 2026-01-22 08:56:07 +01:00
Mauricio Siu
bc6647071f Merge pull request #3501 from Dokploy/open-core-model
feat(license): introduce proprietary license and update core license …
2026-01-21 12:59:22 -06:00
Mauricio Siu
dd10d0b1a4 feat(license): introduce proprietary license and update core license terms 2026-01-21 19:43:33 +01:00
Mauricio Siu
9714695d5a Merge pull request #3500 from Dokploy/security/fix-frame-hijacking
feat(config): add security headers to enhance application security
2026-01-21 11:53:37 -06:00
Mauricio Siu
37e817ff26 feat(config): add security headers to enhance application security 2026-01-21 18:52:57 +01:00
Mauricio Siu
733f4c4a23 fix(db): update security migration command for database configuration 2026-01-21 18:23:32 +01:00
Mauricio Siu
86548a1f24 chore(package): update dokploy version to v0.26.6 2026-01-21 18:07:51 +01:00
Mauricio Siu
dbd354d928 refactor(db): centralize database URL configuration by importing dbUrl from constants 2026-01-21 17:55:59 +01:00
Mauricio Siu
9a9e3dc295 refactor(db): centralize database URL configuration by importing dbUrl from constants 2026-01-21 17:33:06 +01:00
Mauricio Siu
cbd70fe5d0 refactor(db): replace hardcoded DATABASE_URL with dbUrl import for improved configuration 2026-01-21 17:19:28 +01:00
Mauricio Siu
c8ec86c639 chore(env): remove hardcoded DATABASE_URL from production example file 2026-01-21 16:56:30 +01:00
Mauricio Siu
b902c160a2 Merge pull request #3496 from Dokploy/3449-security-hardcoded-token-authentication-for-a-postgres-db
feat(db): enhance database configuration with environment variable su…
2026-01-21 06:32:17 -06:00
Mauricio Siu
8f2a0f8029 feat(db): enhance database configuration with environment variable support
- Introduced a function to read database credentials from a file for improved security.
- Added support for environment variables to configure database connection, replacing hardcoded values.
- Implemented a warning for users relying on deprecated hardcoded credentials, encouraging migration to Docker Secrets.
2026-01-21 13:29:32 +01:00
Mauricio Siu
f334e89108 Merge pull request #3395 from Konders/fix/environment-access-fallback
fix: allow users to open projects with accessible environments
2026-01-21 04:29:50 -06:00
Mauricio Siu
a8fc2adab6 feat(dashboard): add environment availability alert for projects
- Implemented a check for projects with no accessible environments, displaying an alert message to inform users.
- Updated project link behavior to prevent navigation when no environments are available, enhancing user experience.
2026-01-21 11:22:52 +01:00
Mauricio Siu
b8d8d9e5b2 Merge branch 'canary' into fix/environment-access-fallback 2026-01-21 11:09:02 +01:00
Mauricio Siu
6c2457907f Merge pull request #3484 from mikaoelitiana/3483-fix-ellipse
fix: break long project description to avoid ellipse shift
2026-01-21 04:05:19 -06:00
Mika Andrianarijaona
36f082f12a fix: replace truncate with break-all 2026-01-20 17:13:14 +01:00
Mauricio Siu
f3f52c21ab Merge pull request #3488 from Dokploy/feat/hide-builder-if-docker-provider-selected
feat(dashboard): hide builder section for Docker source type
2026-01-20 09:34:56 -06:00
Mauricio Siu
9c565656b1 feat(dashboard): hide builder section for Docker source type
- Added logic to conditionally hide the builder section when the Docker provider is selected, improving user experience by reducing unnecessary UI elements.
2026-01-20 16:33:42 +01:00
Mauricio Siu
983c8d5e9e refactor(cluster): streamline swarm settings documentation and UI components
- Removed unused documentation URLs from menu items in swarm settings.
- Enhanced doc descriptions for better clarity on configuration options.
- Refactored tooltip implementation for improved UI consistency.
2026-01-20 16:31:33 +01:00
Mauricio Siu
9a7b7c0c23 Merge pull request #3486 from Dokploy/feat/convert-swarm-settings-into-form
Feat/convert swarm settings into form
2026-01-20 09:21:52 -06:00
Mauricio Siu
a76147d820 feat(cluster): enhance swarm settings UI with tooltips and documentation links
- Added tooltips to menu items in the swarm settings for better user guidance.
- Included documentation URLs and descriptions for Health Check, Restart Policy, Placement, Update Config, Rollback Config, Mode, Labels, Stop Grace Period, and Endpoint Spec.
- Updated type assertions in rollback and update config forms for improved type safety.
2026-01-20 16:19:12 +01:00
autofix-ci[bot]
7e48b2cf29 [autofix.ci] apply automated fixes 2026-01-20 15:02:58 +00:00
Mauricio Siu
a0d8eb9380 fix(labels-form): improve readability of labelsToSend assignment 2026-01-20 16:02:11 +01:00
Mauricio Siu
e5fcc10db2 feat(cluster): implement advanced swarm settings forms
- Added multiple forms for managing swarm settings including Health Check, Restart Policy, Placement, Update Config, Rollback Config, Mode, Labels, Stop Grace Period, and Endpoint Spec.
- Introduced utility functions for filtering empty values and checking for values to save.
- Enhanced the UI for better navigation and form handling within the dashboard.
- Integrated form validation using Zod and React Hook Form for improved user experience.
2026-01-20 16:01:43 +01:00
Mika Andrianarijaona
a33c6bcce4 fix: truncate project card title to avoid ellise shift
Fixes #3483
2026-01-20 11:51:50 +01:00
Mauricio Siu
5aa5b5538c Merge pull request #3448 from amirhmoradi/patch-1
Delete apps/dokploy/drizzle/0057_damp_prism.sql
2026-01-20 03:38:15 -06:00
Mauricio Siu
49e52ac674 Merge pull request #3479 from Bima42/feat/3475-make-projects-clickable
feat: make projects clickable in breadcrumbs
2026-01-20 03:36:58 -06:00
Mauricio Siu
2a8387bcc2 Merge pull request #3460 from bdkopen/remove-lefthook-and-commitlint
Remove lefthook and commitlint
2026-01-20 03:36:28 -06:00
Bima42
138b193577 feat: make projects clickable in breadcrumbs 2026-01-19 08:51:58 +01:00
Mauricio Siu
f0400495b0 refactor(README): restructure table 2026-01-16 01:18:14 -06:00
Mauricio Siu
240e5cb12f Merge pull request #3462 from Dokploy/activate-monitoring-on-remote-servers-cloud-version
feat(server): add monitoring configuration for cloud setup
2026-01-16 01:11:24 -06:00
Mauricio Siu
2760c16ade Merge pull request #3457 from Dokploy/copilot/fix-envs-in-stack-compose
Fix environment variable resolution for Stack compose deployments
2026-01-16 01:11:06 -06:00
Mauricio Siu
79655b5673 refactor(server): move token generation function to a separate utility for better organization 2026-01-16 01:07:17 -06:00
Mauricio Siu
384fdd01d6 feat(server): add monitoring configuration for cloud setup 2026-01-16 01:05:40 -06:00
bdkopen
c93ec1f06c chore: uninstall disabled @commitlint/cli and @commitlint/config-conventional package 2026-01-15 21:44:00 -05:00
bdkopen
7b3f0273cb chore: uninstall disabled lefthook package 2026-01-15 21:38:17 -05:00
Amir Moradi
66ed6e07c0 Merge branch 'canary' of github.com:amirhmoradi/dokploy into patch-1 2026-01-15 23:49:27 +01:00
copilot-swe-agent[bot]
c1d452bcf7 Complete fix for Stack compose environment variable substitution
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-01-15 15:43:01 +00:00
copilot-swe-agent[bot]
f39b511316 Fix environment variable resolution for Stack compose type
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-01-15 15:39:04 +00:00
copilot-swe-agent[bot]
a2df52ea7c Initial plan 2026-01-15 15:32:01 +00:00
Mauricio Siu
3e5a189177 Merge pull request #3455 from Dokploy/3454-subscribe-issue
chore: update dokploy version to v0.26.5 and modify Stripe session cr…
2026-01-15 09:23:45 -06:00
Mauricio Siu
2b9231dcd1 chore: update dokploy version to v0.26.5 and modify Stripe session creation logic to conditionally set customer or customer_email 2026-01-15 09:18:00 -06:00
Amir Moradi
5d26df9d9f Delete apps/dokploy/drizzle/0057_damp_prism.sql
This migration file is not used nor present in the journal. This is a legacy file that did not get cleaned. I am removing the file to clean the state of the migrations and allow for custom ci/cd scripts to have a clean run and avoid duplicated migration ids (this file conflicts with the `0057_tricky_living_tribunal...`)
2026-01-13 11:13:32 +01:00
Plui Sol
7db1f3a69a feat: add Pushover notification provider 2026-01-12 21:35:07 -05:00
Plui Sol
67f0c93298 Merge remote-tracking branch 'origin/canary' into feature/pushover-notifications 2026-01-12 21:31:48 -05:00
Plui Sol
046c52529b feat: add Pushover notification provider 2026-01-12 21:31:12 -05:00
autofix-ci[bot]
9e8c3f1525 [autofix.ci] apply automated fixes 2026-01-05 16:23:54 +00:00
Illia Shchukin
611b0b3113 fix: allow users to open projects with accessible environments
- Update environment selection to fallback to first accessible environment when default is not accessible
- Fix search command to handle users without default environment access
- Fix projects list to use accessible environment instead of always default
- Add server-side redirect to accessible environment when accessing inaccessible one
- Add comprehensive test coverage for environment access fallback logic

Fixes #3394
2026-01-05 13:55:52 +02:00
stripsior
27dd20b75d chore(databases): bump default postgres version while creating to 18 2026-01-03 15:16:11 +01:00
luojiyin
3142818cf2 fix(docker): use ENV for HOSTNAME and exec form CMD 2025-12-13 15:33:24 +08:00
luojiyin
d8465ac251 config: set port env 2025-12-13 12:36:15 +08:00
luojiyin
c33b41d082 fix(docker): use ENV for HOSTNAME and exec form CMD 2025-12-13 12:32:01 +08:00
luojiyin
3eea875932 code clear 2025-12-13 12:30:30 +08:00
141 changed files with 51620 additions and 2288 deletions

View File

@@ -35,4 +35,5 @@ COPY --from=build /prod/schedules/dist ./dist
COPY --from=build /prod/schedules/package.json ./package.json
COPY --from=build /prod/schedules/node_modules ./node_modules
CMD HOSTNAME=0.0.0.0 && pnpm start
ENV HOSTNAME=0.0.0.0
CMD ["pnpm", "start"]

View File

@@ -35,4 +35,5 @@ COPY --from=build /prod/api/dist ./dist
COPY --from=build /prod/api/package.json ./package.json
COPY --from=build /prod/api/node_modules ./node_modules
CMD HOSTNAME=0.0.0.0 && pnpm start
ENV HOSTNAME=0.0.0.0
CMD ["pnpm", "start"]

View File

@@ -1,8 +1,13 @@
# License
Copyright 2026-present Dokploy Technology, Inc.
## Core License (Apache License 2.0)
Portions of this software are licensed as follows:
Copyright 2025 Mauricio Siu.
* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
## Apache License 2.0
Copyright 2026-present Dokploy Technology, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,12 +20,4 @@ distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and limitations under the License.
## Additional Terms for Specific Features
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

11
LICENSE_PROPRIETARY.md Normal file
View File

@@ -0,0 +1,11 @@
The Dokploy Source Available license (DSAL) version 1.0
Copyright (c) 2026-present Dokploy Technology, Inc.
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License.  Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription.  You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications.  You are not granted any other rights beyond what is expressly stated herein.  Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.

View File

@@ -68,53 +68,21 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
[Github Sponsors](https://github.com/sponsors/Siumauricio)
<!-- Hero Sponsors 🎖 -->
## Sponsors
<!-- Add Hero Sponsors here -->
### Hero Sponsors 🎖
<div>
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
</a>
<a href="https://awesome.tools/" target="_blank">
<img src=".github/sponsors/awesome.png" width="200" height="150" />
</a>
</div>
<!-- Premium Supporters 🥇 -->
<!-- Add Premium Supporters here -->
### Premium Supporters 🥇
<div>
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
</div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Elite Contributors 🥈
<div>
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
</div>
### Supporting Members 🥉
<div>
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
</div>
| Sponsor | Logo | Supporter Level |
|---------|:----:|----------------|
| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="200"/> | 🎖 Hero Sponsor |
| [LX Aer](https://www.lxaer.com/?ref=dokploy) | <img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/> | 🎖 Hero Sponsor |
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | <img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest" width="200"/> | 🎖 Hero Sponsor |
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
### Community Backers 🤝

View File

@@ -1,3 +1,2 @@
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
PORT=3000
NODE_ENV=production

View File

@@ -29,6 +29,7 @@ const baseApp: ApplicationNested = {
applicationId: "",
previewLabels: [],
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
giteaBranch: "",
buildServerId: "",

View File

@@ -0,0 +1,294 @@
import { describe, expect, it } from "vitest";
// Type definitions matching the project structure
type Environment = {
environmentId: string;
name: string;
isDefault: boolean;
};
type Project = {
projectId: string;
name: string;
environments: Environment[];
};
/**
* Helper function that selects the appropriate environment for a user
* This matches the logic used in search-command.tsx and show.tsx
*/
function selectAccessibleEnvironment(
project: Project | null | undefined,
): Environment | null {
if (!project || !project.environments || project.environments.length === 0) {
return null;
}
// Find default environment from accessible environments, or fall back to first accessible environment
const defaultEnvironment =
project.environments.find((environment) => environment.isDefault) ||
project.environments[0];
return defaultEnvironment || null;
}
describe("Environment Access Fallback", () => {
describe("selectAccessibleEnvironment", () => {
it("should return default environment when user has access to it", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should return first accessible environment when user doesn't have access to default", () => {
// Simulating filtered environments (user only has access to development)
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
// Note: production is not in the list because user doesn't have access
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
expect(result?.name).toBe("development");
});
it("should return first environment when no default is marked but environments exist", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
});
it("should return null when project has no accessible environments", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [],
};
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
it("should return null when project is null", () => {
const result = selectAccessibleEnvironment(null);
expect(result).toBeNull();
});
it("should return null when project is undefined", () => {
const result = selectAccessibleEnvironment(undefined);
expect(result).toBeNull();
});
it("should handle project with single accessible environment", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev");
});
it("should prioritize default environment even when it's not first in the array", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should handle multiple default environments by returning the first one found", () => {
// Edge case: multiple environments marked as default (shouldn't happen, but test it)
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod-1",
name: "production-1",
isDefault: true,
},
{
environmentId: "env-prod-2",
name: "production-2",
isDefault: true,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.isDefault).toBe(true);
// Should return the first default found
expect(result?.environmentId).toBe("env-prod-1");
});
it("should work correctly when user has access to multiple environments including default", () => {
const project: Project = {
projectId: "proj-1",
name: "Test Project",
environments: [
{
environmentId: "env-prod",
name: "production",
isDefault: true,
},
{
environmentId: "env-dev",
name: "development",
isDefault: false,
},
{
environmentId: "env-staging",
name: "staging",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-prod");
expect(result?.isDefault).toBe(true);
});
it("should handle real-world scenario: user with only development access", () => {
// This simulates the exact bug we're fixing:
// User has access to development but not production (default)
// The filtered environments array only contains development
const project: Project = {
projectId: "proj-1",
name: "My Project",
environments: [
// Only development is accessible (production was filtered out)
{
environmentId: "env-dev-123",
name: "development",
isDefault: false,
},
],
};
const result = selectAccessibleEnvironment(project);
expect(result).not.toBeNull();
expect(result?.environmentId).toBe("env-dev-123");
expect(result?.name).toBe("development");
// Should not be null even though it's not the default
});
});
describe("Environment selection edge cases", () => {
it("should handle project with environments property as undefined", () => {
const project = {
projectId: "proj-1",
name: "Test Project",
environments: undefined,
} as unknown as Project;
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
it("should handle project with null environments array", () => {
const project = {
projectId: "proj-1",
name: "Test Project",
environments: null,
} as unknown as Project;
const result = selectAccessibleEnvironment(project);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,184 @@
import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
ENVIRONMENT=staging
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
PORT=3000
`;
const environmentEnv = `
NODE_ENV=development
API_URL=https://api.dev.example.com
REDIS_URL=redis://localhost:6379
DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FOO: "development",
BAR: "https://api.dev.example.com",
BAZ: "test",
});
});
it("resolves both project and environment variables for Stack compose", () => {
const serviceEnv = `
ENVIRONMENT=\${{project.ENVIRONMENT}}
NODE_ENV=\${{environment.NODE_ENV}}
API_URL=\${{environment.API_URL}}
DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnviromentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
ENVIRONMENT: "staging",
NODE_ENV: "development",
API_URL: "https://api.dev.example.com",
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
SERVICE_PORT: "4000",
});
});
it("handles multiple environment references in single value for Stack compose", () => {
const multiRefEnv = `
HOST=localhost
PORT=5432
USERNAME=postgres
PASSWORD=secret123
`;
const serviceEnv = `
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
});
});
it("throws error for undefined environment variables in Stack compose", () => {
const serviceWithUndefined = `
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
it("allows service variables to override environment variables in Stack compose", () => {
const serviceOverrideEnv = `
NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnviromentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
);
expect(result).toEqual({
NODE_ENV: "production",
API_URL: "https://api.dev.example.com",
});
});
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
const complexServiceEnv = `
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
);
expect(result).toEqual({
FULL_DATABASE_URL:
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
API_ENDPOINT: "https://api.dev.example.com/staging/api",
SERVICE_NAME: "my-service",
COMPLEX_VAR: "my-service-development-staging",
});
});
it("maintains precedence: service > environment > project in Stack compose", () => {
const conflictingProjectEnv = `
NODE_ENV=production-project
API_URL=https://project.api.com
DATABASE_NAME=project_db
`;
const conflictingEnvironmentEnv = `
NODE_ENV=development-environment
API_URL=https://environment.api.com
DATABASE_NAME=env_db
`;
const serviceWithConflicts = `
NODE_ENV=service-override
PROJECT_ENV=\${{project.NODE_ENV}}
ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnviromentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
);
expect(result).toEqual({
NODE_ENV: "service-override",
PROJECT_ENV: "production-project",
ENV_VAR: "https://environment.api.com",
DB_NAME: "env_db",
});
});
it("handles empty environment variables in Stack compose", () => {
const serviceWithEmpty = `
SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
serviceWithEmpty,
projectEnv,
"",
);
expect(result).toEqual({
SERVICE_VAR: "test",
PROJECT_VAR: "staging",
});
});
});

View File

@@ -8,6 +8,7 @@ const baseApp: ApplicationNested = {
applicationId: "",
previewLabels: [],
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",

View File

@@ -0,0 +1,154 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const endpointSpecFormSchema = z.object({
Mode: z.string().optional(),
});
interface EndpointSpecFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(endpointSpecFormSchema),
defaultValues: {
Mode: undefined,
},
});
useEffect(() => {
if (data?.endpointSpecSwarm) {
const es = data.endpointSpecSwarm;
form.reset({
Mode: es.Mode,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
formData.Mode !== undefined &&
formData.Mode !== null &&
formData.Mode !== "";
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
endpointSpecSwarm: hasAnyValue ? formData : null,
});
toast.success("Endpoint spec updated successfully");
refetch();
} catch {
toast.error("Error updating endpoint spec");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Mode"
render={({ field }) => (
<FormItem>
<FormLabel>Mode</FormLabel>
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select endpoint mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Mode: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Endpoint Spec
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,270 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
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(),
});
interface HealthCheckFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(healthCheckFormSchema),
defaultValues: {
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
},
});
const testCommands = form.watch("Test") || [];
useEffect(() => {
if (data?.healthCheckSwarm) {
const hc = data.healthCheckSwarm;
form.reset({
Test: hc.Test || [],
Interval: hc.Interval,
Timeout: hc.Timeout,
StartPeriod: hc.StartPeriod,
Retries: hc.Retries,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Test && formData.Test.length > 0) ||
formData.Interval !== undefined ||
formData.Timeout !== undefined ||
formData.StartPeriod !== undefined ||
formData.Retries !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
healthCheckSwarm: hasAnyValue ? formData : null,
});
toast.success("Health check updated successfully");
refetch();
} catch {
toast.error("Error updating health check");
} finally {
setIsLoading(false);
}
};
const addTestCommand = () => {
form.setValue("Test", [...testCommands, ""]);
};
const updateTestCommand = (index: number, value: string) => {
const newCommands = [...testCommands];
newCommands[index] = value;
form.setValue("Test", newCommands);
};
const removeTestCommand = (index: number) => {
form.setValue(
"Test",
testCommands.filter((_: string, i: number) => i !== index),
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Test Commands</FormLabel>
<FormDescription>
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
http://localhost:3000/health"])
</FormDescription>
<div className="space-y-2 mt-2">
{testCommands.map((cmd: string, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={cmd}
onChange={(e) => updateTestCommand(index, e.target.value)}
placeholder={
index === 0
? "CMD-SHELL"
: "curl -f http://localhost:3000/health"
}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeTestCommand(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addTestCommand}
>
Add Command
</Button>
</div>
</div>
<FormField
control={form.control}
name="Interval"
render={({ field }) => (
<FormItem>
<FormLabel>Interval (nanoseconds)</FormLabel>
<FormDescription>
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Timeout"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (nanoseconds)</FormLabel>
<FormDescription>
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="StartPeriod"
render={({ field }) => (
<FormItem>
<FormLabel>Start Period (nanoseconds)</FormLabel>
<FormDescription>
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Retries"
render={({ field }) => (
<FormItem>
<FormLabel>Retries</FormLabel>
<FormDescription>
Number of consecutive failures needed to consider container
unhealthy
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Test: [],
Interval: undefined,
Timeout: undefined,
StartPeriod: undefined,
Retries: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Health Check
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,10 @@
export { EndpointSpecForm } from "./endpoint-spec-form";
export { HealthCheckForm } from "./health-check-form";
export { LabelsForm } from "./labels-form";
export { ModeForm } from "./mode-form";
export { PlacementForm } from "./placement-form";
export { RestartPolicyForm } from "./restart-policy-form";
export { RollbackConfigForm } from "./rollback-config-form";
export { StopGracePeriodForm } from "./stop-grace-period-form";
export { UpdateConfigForm } from "./update-config-form";
export { filterEmptyValues, hasValues } from "./utils";

View File

@@ -0,0 +1,200 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
export const labelsFormSchema = z.object({
labels: z
.array(
z.object({
key: z.string(),
value: z.string(),
}),
)
.optional(),
});
interface LabelsFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(labelsFormSchema),
defaultValues: {
labels: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "labels",
});
useEffect(() => {
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
const labelEntries = Object.entries(data.labelsSwarm).map(
([key, value]) => ({
key,
value: value as string,
}),
);
form.reset({ labels: labelEntries });
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
setIsLoading(true);
try {
const labelsObject =
formData.labels?.reduce(
(acc, { key, value }) => {
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
) || {};
// If no labels, send null to clear the database
const labelsToSend =
Object.keys(labelsObject).length > 0 ? labelsObject : null;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
labelsSwarm: labelsToSend,
});
toast.success("Labels updated successfully");
refetch();
} catch {
toast.error("Error updating labels");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Labels</FormLabel>
<FormDescription>
Add key-value labels to your service
</FormDescription>
<div className="space-y-2 mt-2">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<FormField
control={form.control}
name={`labels.${index}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="com.example.app.name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`labels.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder="my-app" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => remove(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ key: "", value: "" })}
>
Add Label
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({ labels: [] });
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Labels
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface ModeFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const ModeForm = ({ id, type }: ModeFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
type: undefined,
Replicas: undefined,
},
});
const modeType = form.watch("type");
useEffect(() => {
if (data?.modeSwarm) {
const mode = data.modeSwarm;
if (mode.Replicated) {
form.reset({
type: "Replicated",
Replicas: mode.Replicated.Replicas,
});
} else if (mode.Global) {
form.reset({
type: "Global",
Replicas: undefined,
});
}
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
// If no type is selected, send null to clear the database
if (!formData.type) {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
modeSwarm: null,
});
toast.success("Mode updated successfully");
refetch();
setIsLoading(false);
return;
}
const modeData =
formData.type === "Replicated"
? { Replicated: { Replicas: formData.Replicas } }
: { Global: {} };
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
modeSwarm: modeData,
});
toast.success("Mode updated successfully");
refetch();
} catch {
toast.error("Error updating mode");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Mode Type</FormLabel>
<FormDescription>
Choose between replicated or global service mode
</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select mode type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Replicated">Replicated</SelectItem>
<SelectItem value="Global">Global</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{modeType === "Replicated" && (
<FormField
control={form.control}
name="Replicas"
render={({ field }) => (
<FormItem>
<FormLabel>Replicas</FormLabel>
<FormDescription>Number of replicas to run</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
type: undefined,
Replicas: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Mode
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,347 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const PreferenceSchema = z.object({
SpreadDescriptor: z.string(),
});
const PlatformSchema = z.object({
Architecture: z.string(),
OS: z.string(),
});
export const placementFormSchema = z.object({
Constraints: z.array(z.string()).optional(),
Preferences: z.array(PreferenceSchema).optional(),
MaxReplicas: z.coerce.number().optional(),
Platforms: z.array(PlatformSchema).optional(),
});
interface PlacementFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(placementFormSchema),
defaultValues: {
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
},
});
const constraints = form.watch("Constraints") || [];
const preferences = form.watch("Preferences") || [];
const platforms = form.watch("Platforms") || [];
useEffect(() => {
if (data?.placementSwarm) {
const placement = data.placementSwarm;
form.reset({
Constraints: placement.Constraints || [],
Preferences:
placement.Preferences?.map((p: any) => ({
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
})) || [],
MaxReplicas: placement.MaxReplicas,
Platforms: placement.Platforms || [],
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue =
(formData.Constraints && formData.Constraints.length > 0) ||
(formData.Preferences && formData.Preferences.length > 0) ||
(formData.Platforms && formData.Platforms.length > 0) ||
formData.MaxReplicas !== undefined;
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
placementSwarm: hasAnyValue
? {
...formData,
Preferences: formData.Preferences?.map((p) => ({
Spread: { SpreadDescriptor: p.SpreadDescriptor },
})),
}
: null,
});
toast.success("Placement updated successfully");
refetch();
} catch {
toast.error("Error updating placement");
} finally {
setIsLoading(false);
}
};
const addConstraint = () => {
form.setValue("Constraints", [...constraints, ""]);
};
const updateConstraint = (index: number, value: string) => {
const newConstraints = [...constraints];
newConstraints[index] = value;
form.setValue("Constraints", newConstraints);
};
const removeConstraint = (index: number) => {
form.setValue(
"Constraints",
constraints.filter((_: string, i: number) => i !== index),
);
};
const addPreference = () => {
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
};
const updatePreference = (index: number, value: string) => {
const newPreferences = [...preferences];
if (newPreferences[index]) {
newPreferences[index].SpreadDescriptor = value;
form.setValue("Preferences", newPreferences);
}
};
const removePreference = (index: number) => {
form.setValue(
"Preferences",
preferences.filter((_: any, i: number) => i !== index),
);
};
const addPlatform = () => {
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
};
const updatePlatform = (
index: number,
field: "Architecture" | "OS",
value: string,
) => {
const newPlatforms = [...platforms];
if (newPlatforms[index]) {
newPlatforms[index][field] = value;
form.setValue("Platforms", newPlatforms);
}
};
const removePlatform = (index: number) => {
form.setValue(
"Platforms",
platforms.filter((_: any, i: number) => i !== index),
);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<FormLabel>Constraints</FormLabel>
<FormDescription>
Placement constraints (e.g., "node.role==manager")
</FormDescription>
<div className="space-y-2 mt-2">
{constraints.map((constraint: string, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={constraint}
onChange={(e) => updateConstraint(index, e.target.value)}
placeholder="node.role==manager"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeConstraint(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addConstraint}
>
Add Constraint
</Button>
</div>
</div>
<div>
<FormLabel>Preferences</FormLabel>
<FormDescription>
Spread preferences for task distribution (e.g.,
"node.labels.region")
</FormDescription>
<div className="space-y-2 mt-2">
{preferences.map((pref: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={pref.SpreadDescriptor}
onChange={(e) => updatePreference(index, e.target.value)}
placeholder="node.labels.region"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePreference(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPreference}
>
Add Preference
</Button>
</div>
</div>
<FormField
control={form.control}
name="MaxReplicas"
render={({ field }) => (
<FormItem>
<FormLabel>Max Replicas</FormLabel>
<FormDescription>
Maximum number of replicas per node
</FormDescription>
<FormControl>
<Input type="number" placeholder="10" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>Platforms</FormLabel>
<FormDescription>
Target platforms for task scheduling
</FormDescription>
<div className="space-y-2 mt-2">
{platforms.map((platform: any, index: number) => (
<div key={index} className="flex gap-2">
<Input
value={platform.Architecture}
onChange={(e) =>
updatePlatform(index, "Architecture", e.target.value)
}
placeholder="amd64"
/>
<Input
value={platform.OS}
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
placeholder="linux"
/>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removePlatform(index)}
>
Remove
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPlatform}
>
Add Platform
</Button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Constraints: [],
Preferences: [],
MaxReplicas: undefined,
Platforms: [],
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Placement
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,219 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const restartPolicyFormSchema = z.object({
Condition: z.string().optional(),
Delay: z.coerce.number().optional(),
MaxAttempts: z.coerce.number().optional(),
Window: z.coerce.number().optional(),
});
interface RestartPolicyFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(restartPolicyFormSchema),
defaultValues: {
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
},
});
useEffect(() => {
if (data?.restartPolicySwarm) {
form.reset({
Condition: data.restartPolicySwarm.Condition,
Delay: data.restartPolicySwarm.Delay,
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
Window: data.restartPolicySwarm.Window,
});
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof restartPolicyFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
restartPolicySwarm: hasAnyValue ? formData : null,
});
toast.success("Restart policy updated successfully");
refetch();
} catch {
toast.error("Error updating restart policy");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Condition"
render={({ field }) => (
<FormItem>
<FormLabel>Condition</FormLabel>
<FormDescription>When to restart the container</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select restart condition" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="on-failure">On Failure</SelectItem>
<SelectItem value="any">Any</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>
Wait time between restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxAttempts"
render={({ field }) => (
<FormItem>
<FormLabel>Max Attempts</FormLabel>
<FormDescription>
Maximum number of restart attempts
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Window"
render={({ field }) => (
<FormItem>
<FormLabel>Window (nanoseconds)</FormLabel>
<FormDescription>
Time window to evaluate restart policy
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Condition: undefined,
Delay: undefined,
MaxAttempts: undefined,
Window: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Restart Policy
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,257 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const rollbackConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface RollbackConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(rollbackConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.rollbackConfigSwarm) {
form.reset(data.rollbackConfigSwarm);
}
}, [data, form]);
const onSubmit = async (
formData: z.infer<typeof rollbackConfigFormSchema>,
) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Rollback config updated successfully");
refetch();
} catch {
toast.error("Error updating rollback config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to rollback simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task rollbacks</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on rollback failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after rollback
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Rollback order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Rollback Config
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,158 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface StopGracePeriodFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
defaultValues: {
value: null as bigint | null,
},
});
useEffect(() => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
form.reset({
value: normalizedValue,
});
}
}, [data, form]);
const onSubmit = async (formData: any) => {
setIsLoading(true);
try {
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
stopGracePeriodSwarm: formData.value,
});
toast.success("Stop grace period updated successfully");
refetch();
} catch {
toast.error("Error updating stop grace period");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
<FormDescription>
Time to wait before forcefully killing the container
<br />
Examples: 30000000000 (30s), 120000000000 (2m)
</FormDescription>
<FormControl>
<Input
type="number"
placeholder="30000000000"
{...field}
value={
field?.value !== null && field?.value !== undefined
? field.value.toString()
: ""
}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
value: null,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Stop Grace Period
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,264 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
export const updateConfigFormSchema = z.object({
Parallelism: z.coerce.number().optional(),
Delay: z.coerce.number().optional(),
FailureAction: z.string().optional(),
Monitor: z.coerce.number().optional(),
MaxFailureRatio: z.coerce.number().optional(),
Order: z.string().optional(),
});
interface UpdateConfigFormProps {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
}
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<any>({
resolver: zodResolver(updateConfigFormSchema),
defaultValues: {
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
},
});
useEffect(() => {
if (data?.updateConfigSwarm) {
const config = data.updateConfigSwarm;
form.reset({
Parallelism: config.Parallelism,
Delay: config.Delay,
FailureAction: config.FailureAction,
Monitor: config.Monitor,
MaxFailureRatio: config.MaxFailureRatio,
Order: config.Order,
});
}
}, [data, form]);
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
setIsLoading(true);
try {
// Check if all values are empty, if so, send null to clear the database
const hasAnyValue = Object.values(formData).some(
(value) => value !== undefined && value !== null && value !== "",
);
await mutateAsync({
applicationId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
mongoId: id || "",
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
});
toast.success("Update config updated successfully");
refetch();
} catch {
toast.error("Error updating update config");
} finally {
setIsLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="Parallelism"
render={({ field }) => (
<FormItem>
<FormLabel>Parallelism</FormLabel>
<FormDescription>
Number of tasks to update simultaneously
</FormDescription>
<FormControl>
<Input type="number" placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Delay"
render={({ field }) => (
<FormItem>
<FormLabel>Delay (nanoseconds)</FormLabel>
<FormDescription>Delay between task updates</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="FailureAction"
render={({ field }) => (
<FormItem>
<FormLabel>Failure Action</FormLabel>
<FormDescription>Action on update failure</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select failure action" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pause">Pause</SelectItem>
<SelectItem value="continue">Continue</SelectItem>
<SelectItem value="rollback">Rollback</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Monitor"
render={({ field }) => (
<FormItem>
<FormLabel>Monitor (nanoseconds)</FormLabel>
<FormDescription>
Duration to monitor for failure after update
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="MaxFailureRatio"
render={({ field }) => (
<FormItem>
<FormLabel>Max Failure Ratio</FormLabel>
<FormDescription>
Maximum failure ratio tolerated (0-1)
</FormDescription>
<FormControl>
<Input type="number" step="0.01" placeholder="0.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="Order"
render={({ field }) => (
<FormItem>
<FormLabel>Order</FormLabel>
<FormDescription>Update order strategy</FormDescription>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select order" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="stop-first">Stop First</SelectItem>
<SelectItem value="start-first">Start First</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset({
Parallelism: undefined,
Delay: undefined,
FailureAction: undefined,
Monitor: undefined,
MaxFailureRatio: undefined,
Order: undefined,
});
}}
>
Clear
</Button>
<Button type="submit" isLoading={isLoading}>
Save Update Config
</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,31 @@
/**
* Filters out undefined, null, and empty string values from form data
* Only returns fields that have actual values
*/
export const filterEmptyValues = (
formData: Record<string, any>,
): Record<string, any> => {
return Object.entries(formData).reduce(
(acc, [key, value]) => {
// Keep arrays even if empty (they might be intentionally cleared)
if (Array.isArray(value)) {
if (value.length > 0) {
acc[key] = value;
}
}
// For other values, filter out undefined, null, and empty strings
else if (value !== undefined && value !== null && value !== "") {
acc[key] = value;
}
return acc;
},
{} as Record<string, any>,
);
};
/**
* Checks if filtered data has any values to save
*/
export const hasValues = (data: Record<string, any>): boolean => {
return Object.keys(data).length > 0;
};

View File

@@ -207,6 +207,11 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
}
}, [data, form]);
// Hide builder section when Docker provider is selected
if (data?.sourceType === "docker") {
return null;
}
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
applicationId,

View File

@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -82,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
repository: {
owner: "",
repo: "",
slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
enabled:
!!repository?.owner &&
!!(repository?.slug || repository?.repo) &&
!!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
slug: data.bitbucketRepositorySlug || "",
},
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
@@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
slug: "",
});
form.setValue("branch", "");
}}
@@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -271,6 +279,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
slug: repo.slug,
});
form.setValue("branch", "");
}}

View File

@@ -1,3 +1,4 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
ExternalLink,
FileText,
@@ -29,7 +30,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";

View File

@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
@@ -82,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
repository: {
owner: "",
repo: "",
slug: "",
},
bitbucketId: "",
branch: "",
@@ -114,11 +116,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
} = api.bitbucket.getBitbucketBranches.useQuery(
{
owner: repository?.owner,
repo: repository?.repo,
repo: repository?.slug || repository?.repo || "",
bitbucketId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
enabled:
!!repository?.owner &&
!!(repository?.slug || repository?.repo) &&
!!bitbucketId,
},
);
@@ -129,6 +134,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
repository: {
repo: data.bitbucketRepository || "",
owner: data.bitbucketOwner || "",
slug: data.bitbucketRepositorySlug || "",
},
composePath: data.composePath,
bitbucketId: data.bitbucketId || "",
@@ -142,6 +148,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
await mutateAsync({
bitbucketBranch: data.branch,
bitbucketRepository: data.repository.repo,
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
bitbucketOwner: data.repository.owner,
bitbucketId: data.bitbucketId,
composePath: data.composePath,
@@ -183,6 +190,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: "",
repo: "",
slug: "",
});
form.setValue("branch", "");
}}
@@ -219,7 +227,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -273,6 +281,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
slug: repo.slug,
});
form.setValue("branch", "");
}}

View File

@@ -129,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Docker Image</FormLabel>
<FormControl>
<Input placeholder="postgres:15" {...field} />
<Input placeholder="postgres:18" {...field} />
</FormControl>
<FormMessage />

View File

@@ -58,7 +58,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:15",
postgres: "postgres:18",
redis: "redis:7",
};

View File

@@ -288,9 +288,12 @@ export const ShowProjects = () => {
)
.some(Boolean);
const productionEnvironment = project?.environments.find(
(env) => env.isDefault,
);
// Find default environment from accessible environments, or fall back to first accessible environment
const accessibleEnvironment =
project?.environments.find((env) => env.isDefault) ||
project?.environments?.[0];
const hasNoEnvironments = !accessibleEnvironment;
return (
<div
@@ -298,7 +301,16 @@ export const ShowProjects = () => {
className="w-full lg:max-w-md"
>
<Link
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.environmentId}`}
href={
hasNoEnvironments
? "#"
: `/dashboard/project/${project.projectId}/environment/${accessibleEnvironment?.environmentId}`
}
onClick={(e) => {
if (hasNoEnvironments) {
e.preventDefault();
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{haveServicesWithDomains ? (
@@ -419,7 +431,7 @@ export const ShowProjects = () => {
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5">
<span className="flex flex-col gap-1.5 ">
<div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none">
@@ -427,9 +439,19 @@ export const ShowProjects = () => {
</span>
</div>
<span className="text-sm font-medium text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground break-all">
{project.description}
</span>
{hasNoEnvironments && (
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span className="text-xs text-yellow-600 dark:text-yellow-400">
You have access to this project but no
environments are available
</span>
</div>
)}
</span>
<div className="flex self-start space-x-1">
<DropdownMenu>

View File

@@ -89,7 +89,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => {
// Find default environment, or fall back to first environment
// Find default environment from accessible environments, or fall back to first accessible environment
const defaultEnvironment =
project.environments.find(
(environment) => environment.isDefault,

View File

@@ -15,6 +15,7 @@ import {
GotifyIcon,
LarkIcon,
NtfyIcon,
PushoverIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -114,6 +115,16 @@ export const notificationSchema = z.discriminatedUnion("type", [
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("pushover"),
userKey: z.string().min(1, { message: "User Key is required" }),
apiToken: z.string().min(1, { message: "API Token is required" }),
priority: z.number().min(-2).max(2).default(0),
retry: z.number().min(30).nullish(),
expire: z.number().min(1).max(10800).nullish(),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("custom"),
@@ -166,6 +177,10 @@ export const notificationsMap = {
icon: <NtfyIcon />,
label: "ntfy",
},
pushover: {
icon: <PushoverIcon />,
label: "Pushover",
},
custom: {
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
label: "Custom",
@@ -209,6 +224,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
const { mutateAsync: testPushoverConnection, isLoading: isLoadingPushover } =
api.notification.testPushoverConnection.useMutation();
const customMutation = notificationId
? api.notification.updateCustom.useMutation()
: api.notification.createCustom.useMutation();
@@ -233,6 +251,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const pushoverMutation = notificationId
? api.notification.updatePushover.useMutation()
: api.notification.createPushover.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -393,6 +414,23 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "pushover") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
userKey: notification.pushover?.userKey,
apiToken: notification.pushover?.apiToken,
priority: notification.pushover?.priority,
retry: notification.pushover?.retry ?? undefined,
expire: notification.pushover?.expire ?? undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
}
} else {
form.reset();
@@ -408,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
ntfy: ntfyMutation,
lark: larkMutation,
custom: customMutation,
pushover: pushoverMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -559,6 +598,28 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
customId: notification?.customId || "",
});
} else if (data.type === "pushover") {
if (data.priority === 2 && (data.retry == null || data.expire == null)) {
toast.error("Retry and expire are required for emergency priority (2)");
return;
}
promise = pushoverMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
userKey: data.userKey,
apiToken: data.apiToken,
priority: data.priority,
retry: data.priority === 2 ? data.retry : undefined,
expire: data.priority === 2 ? data.expire : undefined,
name: data.name,
dockerCleanup: dockerCleanup,
serverThreshold: serverThreshold,
notificationId: notificationId || "",
pushoverId: notification?.pushoverId || "",
});
}
if (promise) {
@@ -1255,6 +1316,147 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "pushover" && (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input placeholder="ub3de9kl2q..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="a3d9k2q7m4..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={0}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="0"
value={field.value ?? 0}
onChange={(e) => {
const value = e.target.value;
if (value === "" || value === "-") {
field.onChange(0);
} else {
const priority = Number.parseInt(value);
if (
!Number.isNaN(priority) &&
priority >= -2 &&
priority <= 2
) {
field.onChange(priority);
}
}
}}
type="number"
min={-2}
max={2}
/>
</FormControl>
<FormDescription>
Message priority (-2 to 2, default: 0, emergency: 2)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("priority") === 2 && (
<>
<FormField
control={form.control}
name="retry"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Retry (seconds)</FormLabel>
<FormControl>
<Input
placeholder="30"
{...field}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
} else {
const retry = Number.parseInt(value);
if (!Number.isNaN(retry)) {
field.onChange(retry);
}
}
}}
type="number"
min={30}
/>
</FormControl>
<FormDescription>
How often (in seconds) to retry. Minimum 30
seconds.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expire"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Expire (seconds)</FormLabel>
<FormControl>
<Input
placeholder="3600"
{...field}
value={field.value ?? ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
} else {
const expire = Number.parseInt(value);
if (!Number.isNaN(expire)) {
field.onChange(expire);
}
}
}}
type="number"
min={1}
max={10800}
/>
</FormControl>
<FormDescription>
How long to keep retrying (max 10800 seconds / 3
hours).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
@@ -1428,7 +1630,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark ||
isLoadingCustom
isLoadingCustom ||
isLoadingPushover
}
variant="secondary"
type="button"
@@ -1497,6 +1700,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
endpoint: data.endpoint,
headers: headersRecord,
});
} else if (data.type === "pushover") {
if (
data.priority === 2 &&
(data.retry == null || data.expire == null)
) {
throw new Error(
"Retry and expire are required for emergency priority (2)",
);
}
await testPushoverConnection({
userKey: data.userKey,
apiToken: data.apiToken,
priority: data.priority,
retry: data.priority === 2 ? data.retry : undefined,
expire: data.priority === 2 ? data.expire : undefined,
});
}
toast.success("Connection Success");
} catch (error) {

View File

@@ -41,7 +41,7 @@ const profileSchema = z.object({
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
name: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
allowImpersonation: z.boolean().optional().default(false),
});
@@ -91,7 +91,7 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: "",
allowImpersonation: data?.user?.allowImpersonation || false,
name: data?.user?.firstName || "",
firstName: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
resolver: zodResolver(profileSchema),
@@ -106,7 +106,7 @@ export const ProfileForm = () => {
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
allowImpersonation: data?.user?.allowImpersonation,
name: data?.user?.firstName || "",
firstName: data?.user?.firstName || "",
lastName: data?.user?.lastName || "",
},
{
@@ -131,7 +131,7 @@ export const ProfileForm = () => {
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
firstName: values.firstName || undefined,
lastName: values.lastName || undefined,
});
await refetch();
@@ -141,7 +141,7 @@ export const ProfileForm = () => {
password: "",
image: values.image,
currentPassword: "",
name: values.name || "",
firstName: values.firstName || "",
lastName: values.lastName || "",
});
} catch (error) {
@@ -184,7 +184,7 @@ export const ProfileForm = () => {
<div className="space-y-4">
<FormField
control={form.control}
name="name"
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>

View File

@@ -231,3 +231,29 @@ export const NtfyIcon = ({ className }: Props) => {
</svg>
);
};
export const PushoverIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 600 600"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<g stroke="none" strokeWidth="1">
<ellipse
style={{ fillRule: "evenodd" }}
fill="#249DF1"
transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)"
cx="216.308"
cy="152.076"
rx="296.855"
ry="296.855"
/>
<path
fill="#FFFFFF"
d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z"
/>
</g>
</svg>
);
};

View File

@@ -4,21 +4,35 @@ import { cn } from "@/lib/utils";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { api } from "@/utils/api";
interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const appName = whitelabel?.whitelabelAppName ?? "Dokploy";
const logoUrl =
whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl;
return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
<div className="absolute inset-0 bg-muted" />
{whitelabel?.whitelabelLoginBackgroundImageUrl && (
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
style={{
backgroundImage: `url(${whitelabel.whitelabelLoginBackgroundImageUrl})`,
}}
/>
)}
<Link
href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" />
Dokploy
<Logo className="size-10" logoUrl={logoUrl ?? undefined} />
{appName}
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">

View File

@@ -18,11 +18,13 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
HeartIcon,
Key,
KeyRound,
Loader2,
LogIn,
type LucideIcon,
Package,
Palette,
PieChart,
Server,
ShieldCheck,
@@ -397,6 +399,33 @@ const MENU: Menu = {
// Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
},
{
isSingle: true,
title: "License",
url: "/dashboard/settings/license",
icon: Key,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
title: "SSO",
url: "/dashboard/settings/sso",
icon: LogIn,
// Enabled for admins in both cloud and self-hosted (enterprise)
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
isSingle: true,
title: "Whitelabeling",
url: "/dashboard/settings/whitelabelling",
icon: Palette,
// Enterprise only page shows gate if no license
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
],
help: [
@@ -410,18 +439,6 @@ const MENU: Menu = {
url: "https://discord.gg/2tBnJ3jDJc",
icon: CircleHelp,
},
{
name: "Sponsor",
url: "https://opencollective.com/dokploy",
icon: ({ className }) => (
<HeartIcon
className={cn(
"text-red-500 fill-red-600 animate-heartbeat",
className,
)}
/>
),
},
],
} as const;
@@ -539,6 +556,7 @@ function SidebarLogo() {
refetch,
isLoading,
} = api.organization.all.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
@@ -604,7 +622,11 @@ function SidebarLogo() {
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={activeOrganization?.logo || undefined}
logoUrl={
activeOrganization?.logo ||
whitelabel?.whitelabelLogoUrl ||
undefined
}
/>
</div>
<div
@@ -614,7 +636,9 @@ function SidebarLogo() {
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
{activeOrganization?.name ??
whitelabel?.whitelabelAppName ??
"Select Organization"}
</p>
</div>
</div>

View File

@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignInWithGithub() {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
const { error } = await authClient.signIn.social({
provider: "github",
});
if (error) {
toast.error(error.message);
return;
}
} catch (err) {
toast.error("An error occurred while signing in with GitHub", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="outline"
type="button"
className="w-full mb-4"
onClick={handleClick}
isLoading={isLoading}
>
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
/>
</svg>
Sign in with GitHub
</Button>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
export function SignInWithGoogle() {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
const { error } = await authClient.signIn.social({
provider: "google",
});
if (error) {
toast.error(error.message);
return;
}
} catch (err) {
toast.error("An error occurred while signing in with Google", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="outline"
type="button"
className="w-full mb-4"
onClick={handleClick}
isLoading={isLoading}
>
<svg viewBox="0 0 24 24" className="mr-2 size-4">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Loader2, Lock } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
interface EnterpriseFeatureLockedProps {
/** Optional title override */
title?: string;
/** Optional description override */
description?: string;
/** Optional custom CTA label */
ctaLabel?: string;
/** Optional CTA href (default: /dashboard/settings/license) */
ctaHref?: string;
/** Compact variant (less padding, smaller icon) */
compact?: boolean;
}
/**
* Displays a locked state for enterprise features when the user has no valid license.
* Use standalone or via EnterpriseFeatureGate.
*/
export function EnterpriseFeatureLocked({
title = "Enterprise feature",
description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.",
ctaLabel = "Go to License",
ctaHref = "/dashboard/settings/license",
compact = false,
}: EnterpriseFeatureLockedProps) {
return (
<Card className="border-dashed bg-transparent">
<CardHeader className={compact ? "pb-2" : undefined}>
<div className="flex flex-col items-center gap-3 text-center">
<div
className={
compact
? "rounded-full bg-muted p-3"
: "rounded-full bg-muted p-4"
}
>
<Lock
className={
compact
? "size-6 text-muted-foreground"
: "size-8 text-muted-foreground"
}
/>
</div>
<div className="space-y-1">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription className="max-w-sm mx-auto">
{description}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className={compact ? "pt-0" : undefined}>
<div className="flex justify-center">
<Button asChild variant="secondary" size={compact ? "sm" : "default"}>
<Link href={ctaHref}>{ctaLabel}</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
interface EnterpriseFeatureGateProps {
children: React.ReactNode;
/** Props for the locked state when license is invalid */
lockedProps?: Omit<EnterpriseFeatureLockedProps, "compact">;
/** Show loading spinner while checking license */
fallback?: React.ReactNode;
}
/**
* Renders children only when the instance has a valid enterprise license.
* Otherwise shows EnterpriseFeatureLocked.
*/
export function EnterpriseFeatureGate({
children,
lockedProps,
fallback,
}: EnterpriseFeatureGateProps) {
const { data: haveValidLicense, isLoading } =
api.licenseKey.haveValidLicenseKey.useQuery();
if (isLoading) {
if (fallback) return <>{fallback}</>;
return (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Checking license...
</span>
</div>
);
}
if (!haveValidLicense) {
return <EnterpriseFeatureLocked {...lockedProps} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,232 @@
import { Key, Loader2, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
export function LicenseKeySettings() {
const utils = api.useUtils();
const { data, isLoading } = api.licenseKey.getEnterpriseSettings.useQuery();
const { mutateAsync: updateEnterpriseSettings, isLoading: isSaving } =
api.licenseKey.updateEnterpriseSettings.useMutation();
const { mutateAsync: activateLicenseKey, isLoading: isActivating } =
api.licenseKey.activate.useMutation();
const { mutateAsync: validateLicenseKey, isLoading: isValidating } =
api.licenseKey.validate.useMutation();
const { mutateAsync: deactivateLicenseKey, isLoading: isDeactivating } =
api.licenseKey.deactivate.useMutation();
const { data: haveValidLicenseKey, isLoading: isCheckingLicenseKey } =
api.licenseKey.haveValidLicenseKey.useQuery();
const [licenseKey, setLicenseKey] = useState("");
useEffect(() => {
if (data?.licenseKey) {
setLicenseKey(data.licenseKey);
}
}, [data?.licenseKey]);
const enabled = !!data?.enableEnterpriseFeatures;
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
{isCheckingLicenseKey ? (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Checking license key...
</span>
</div>
) : (
<>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Key className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">License Key</CardTitle>
</div>
{enabled && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={enabled}
disabled={isLoading || isSaving || isDeactivating}
onCheckedChange={async (next) => {
try {
await updateEnterpriseSettings({
enableEnterpriseFeatures: next,
});
await utils.licenseKey.getEnterpriseSettings.invalidate();
toast.success("Enterprise features updated");
} catch (error) {
console.error(error);
toast.error("Failed to update enterprise features");
}
}}
/>
</div>
)}
</div>
<p className="text-sm text-muted-foreground">
To unlock extra features you need an enterprise license key.
Contact us{" "}
<Link
href="https://dokploy.com/contact"
target="_blank"
rel="noreferrer"
className="underline underline-offset-4"
>
here
</Link>
.
</p>
</div>
{enabled ? (
<>
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="licenseKey">
License Key
</label>
<Input
id="licenseKey"
placeholder="Enter your enterprise license key"
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
/>
</div>
<div className="md:justify-self-end flex gap-2">
{haveValidLicenseKey && (
<DialogAction
title="Deactivate License Key"
description="Are you sure you want to deactivate this license key? This will disable enterprise features."
onClick={async () => {
try {
await deactivateLicenseKey();
await utils.licenseKey.getEnterpriseSettings.invalidate();
await utils.licenseKey.haveValidLicenseKey.invalidate();
setLicenseKey("");
toast.success("License key deactivated");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to deactivate license key",
);
}
}}
disabled={isDeactivating || !haveValidLicenseKey}
>
<Button
variant="destructive"
disabled={isDeactivating || !haveValidLicenseKey}
isLoading={isDeactivating}
>
Deactivate
</Button>
</DialogAction>
)}
{haveValidLicenseKey && (
<Button
variant="outline"
disabled={
isSaving || isCheckingLicenseKey || isDeactivating
}
isLoading={isValidating}
onClick={async () => {
try {
const valid = await validateLicenseKey();
if (valid) {
toast.success("License key is valid");
} else {
toast.error("License key is invalid");
}
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to validate license key",
);
}
}}
>
Validate
</Button>
)}
{!haveValidLicenseKey && (
<Button
variant="secondary"
disabled={isSaving || isValidating || isDeactivating}
isLoading={isActivating}
onClick={async () => {
try {
await activateLicenseKey({ licenseKey });
await utils.licenseKey.getEnterpriseSettings.invalidate();
await utils.licenseKey.haveValidLicenseKey.invalidate();
toast.success("License key activated");
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to activate license key",
);
}
}}
>
Activate
</Button>
)}
</div>
</div>
</>
) : (
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
<div className="flex flex-col items-center gap-2 max-w-[400px]">
<div className="rounded-full bg-muted p-4">
<ShieldCheck className="size-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold">Enterprise Features</h3>
<p className="text-sm text-muted-foreground">
Unlock advanced capabilities like SSO, Audit logs,
whitelabeling and more.
</p>
</div>
</div>
<Button
onClick={async () => {
try {
await updateEnterpriseSettings({
enableEnterpriseFeatures: true,
});
await utils.licenseKey.getEnterpriseSettings.invalidate();
toast.success("Enterprise features enabled");
} catch (error) {
console.error(error);
toast.error("Failed to enable enterprise features");
}
}}
isLoading={isSaving}
disabled={isLoading || isDeactivating}
>
Enable Enterprise Features
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,347 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import type { FieldArrayPath } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
const domainsArraySchema = z
.array(z.string().trim())
.superRefine((arr, ctx) => {
const filled = arr.filter((s) => s.length > 0);
if (filled.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one domain is required",
path: [],
});
}
});
const scopesArraySchema = z.array(z.string().trim());
const oidcProviderSchema = z.object({
providerId: z.string().min(1, "Provider ID is required").trim(),
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
domains: domainsArraySchema,
clientId: z.string().min(1, "Client ID is required").trim(),
clientSecret: z.string().min(1, "Client secret is required"),
scopes: scopesArraySchema,
});
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
children: React.ReactNode;
}
const formDefaultValues = {
providerId: "",
issuer: "",
domains: [""],
clientId: "",
clientSecret: "",
scopes: [...DEFAULT_SCOPES],
};
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<OidcProviderForm>,
});
const {
fields: scopeFields,
append: appendScope,
remove: removeScope,
} = useFieldArray({
control: form.control,
name: "scopes" as FieldArrayPath<OidcProviderForm>,
});
const isSubmitting = form.formState.isSubmitting;
const onSubmit = async (data: OidcProviderForm) => {
try {
const scopes = data.scopes.filter(Boolean).length
? data.scopes.filter(Boolean)
: DEFAULT_SCOPES;
const domain = data.domains
.map((d) => d.trim())
.filter(Boolean)
.join(",");
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domain,
oidcConfig: {
clientId: data.clientId,
clientSecret: data.clientSecret,
scopes,
pkce: true,
// Keycloak (and many IdPs) send preferred_username; better-auth expects name
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "preferred_username",
image: "picture",
},
},
});
toast.success("OIDC provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to register SSO provider",
);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogDescription>
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input placeholder="e.g. okta or my-idp" {...field} />
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input placeholder="https://idp.example.com" {...field} />
</FormControl>
<FormDescription>
Discovery document is fetched from{" "}
<code className="rounded bg-muted px-1">
{"{issuer}"}/.well-known/openid-configuration
</code>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Domains</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => (append as (value: string) => void)("")}
>
<Plus className="mr-1 size-4" />
Add domain
</Button>
</div>
<p className="text-xs text-muted-foreground">
Email domains that use this provider (sign-in by email and org
assignment; subdomains matched automatically).
</p>
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`domains.${index}`}
render={({ field: inputField }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="company.com"
className="flex-1"
{...inputField}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => remove(index)}
disabled={fields.length <= 1}
>
<Trash2 className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
{(() => {
const err = form.formState.errors.domains;
const msg =
typeof err?.message === "string"
? err.message
: (err as { root?: { message?: string } } | undefined)?.root
?.message;
return msg ? (
<p className="text-sm font-medium text-destructive">{msg}</p>
) : null;
})()}
</div>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input placeholder="Client ID from IdP" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Client secret from IdP"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Scopes (optional)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => (appendScope as (value: string) => void)("")}
>
<Plus className="mr-1 size-4" />
Add scope
</Button>
</div>
<FormDescription>
OIDC scopes to request (e.g. openid, email, profile). If empty,
openid, email and profile are used.
</FormDescription>
{scopeFields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`scopes.${index}`}
render={({ field: inputField }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="openid"
className="flex-1"
{...inputField}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => removeScope(index)}
disabled={scopeFields.length <= 1}
>
<Trash2 className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,322 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
const domainsArraySchema = z
.array(z.string().trim())
.superRefine((arr, ctx) => {
const filled = arr.filter((s) => s.length > 0);
if (filled.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one domain is required",
path: [],
});
}
});
const samlProviderSchema = z.object({
providerId: z.string().min(1, "Provider ID is required").trim(),
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
domains: domainsArraySchema,
entryPoint: z
.string()
.min(1, "IdP SSO URL is required")
.url("Invalid URL")
.trim(),
cert: z.string().min(1, "IdP signing certificate is required"),
callbackUrl: z
.string()
.min(1, "Callback URL is required")
.url("Invalid URL")
.trim(),
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
});
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
children: React.ReactNode;
}
const formDefaultValues: SamlProviderForm = {
providerId: "",
issuer: "",
domains: [""],
entryPoint: "",
cert: "",
callbackUrl: "",
audience: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
});
const isSubmitting = form.formState.isSubmitting;
const onSubmit = async (data: SamlProviderForm) => {
try {
const domain = data.domains
.map((d) => d.trim())
.filter(Boolean)
.join(",");
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domain,
samlConfig: {
entryPoint: data.entryPoint,
cert: data.cert,
callbackUrl: data.callbackUrl,
audience: data.audience,
wantAssertionsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
spMetadata: {
entityID: data.audience,
},
},
});
toast.success("SAML provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to register SAML provider",
);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogDescription>
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="providerId"
render={({ field }) => (
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer URL</FormLabel>
<FormControl>
<Input placeholder="https://idp.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Domains</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => append("")}
>
<Plus className="mr-1 size-4" />
Add domain
</Button>
</div>
<FormDescription>
Email domains that use this provider (sign-in by email and org
assignment; subdomains matched automatically).
</FormDescription>
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`domains.${index}`}
render={({ field: inputField }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="company.com"
className="flex-1"
{...inputField}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => remove(index)}
disabled={fields.length <= 1}
>
<Trash2 className="size-4" />
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
{(() => {
const err = form.formState.errors.domains;
const msg =
typeof err?.message === "string"
? err.message
: (err as { root?: { message?: string } } | undefined)?.root
?.message;
return msg ? (
<p className="text-sm font-medium text-destructive">{msg}</p>
) : null;
})()}
</div>
<FormField
control={form.control}
name="entryPoint"
render={({ field }) => (
<FormItem>
<FormLabel>IdP SSO URL (Entry point)</FormLabel>
<FormControl>
<Input
placeholder="https://idp.example.com/sso"
{...field}
/>
</FormControl>
<FormDescription>
Single Sign-On URL from your IdP&apos;s SAML setup.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cert"
render={({ field }) => (
<FormItem>
<FormLabel>IdP signing certificate (X.509)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste IdP signing certificate (PEM, BEGIN CERTIFICATE / END CERTIFICATE)"
rows={4}
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="callbackUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Callback URL (ACS)</FormLabel>
<FormControl>
<Input
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
{...field}
/>
</FormControl>
<FormDescription>
Use the callback URL shown in your IdP app config for this
provider.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="audience"
render={({ field }) => (
<FormItem>
<FormLabel>Audience (Entity ID)</FormLabel>
<FormControl>
<Input placeholder="https://yourapp.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
Register provider
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, LogIn } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
const ssoEmailSchema = z.object({
email: z
.string()
.min(1, "Enter your work email")
.email("Enter a valid email address")
.transform((v) => v.trim()),
});
type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
interface SignInWithSSOProps {
/** Content shown when SSO is collapsed (e.g. email/password form) */
children: React.ReactNode;
}
export function SignInWithSSO({ children }: SignInWithSSOProps) {
const [expanded, setExpanded] = useState(false);
const form = useForm<SSOEmailForm>({
resolver: zodResolver(ssoEmailSchema),
defaultValues: { email: "" },
});
const onSubmit = async (values: SSOEmailForm) => {
try {
const { data, error } = await authClient.signIn.sso({
email: values.email,
callbackURL: "/dashboard/projects",
});
if (error) {
toast.error(error.message ?? "Failed to sign in with SSO");
return;
}
if (data?.url) {
window.location.href = data.url;
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to sign in with SSO",
);
}
};
if (!expanded) {
return (
<div className="mb-4 space-y-2">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => setExpanded(true)}
>
<LogIn className="mr-2 size-4" />
Sign in with SSO
</Button>
{children}
</div>
);
}
return (
<div className="mb-4 space-y-2">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex gap-2">
<Input
type="email"
placeholder="you@company.com"
className="flex-1"
autoComplete="email"
disabled={form.formState.isSubmitting}
{...field}
/>
<Button
type="submit"
variant="outline"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Continue"
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-muted-foreground hover:underline"
>
Use email and password instead
</button>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,368 @@
"use client";
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
type ProviderForDetails = {
id: string | null;
providerId: string;
issuer: string;
domain: string;
oidcConfig: string | null;
samlConfig: string | null;
organizationId: string | null;
};
function parseOidcConfig(config: string | null): {
clientId?: string;
scopes?: string[];
} | null {
if (!config) return null;
try {
const parsed = JSON.parse(config) as {
clientId?: string;
scopes?: string[];
};
return { clientId: parsed.clientId, scopes: parsed.scopes };
} catch {
return null;
}
}
function parseSamlConfig(
config: string | null,
): { entryPoint?: string } | null {
if (!config) return null;
try {
const parsed = JSON.parse(config) as { entryPoint?: string };
return { entryPoint: parsed.entryPoint };
} catch {
return null;
}
}
export const SSOSettings = () => {
const utils = api.useUtils();
const [detailsProvider, setDetailsProvider] =
useState<ProviderForDetails | null>(null);
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
api.sso.deleteProvider.useMutation();
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<LogIn className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
</div>
<CardDescription>
Configure OIDC or SAML identity providers for enterprise sign-in.
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
{isLoading ? (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Loading providers...
</span>
</div>
) : (
<>
{providers && providers.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<RegisterOidcDialog>
<Button variant="secondary" size="sm">
<LogIn className="mr-2 size-4" />
Add OIDC provider
</Button>
</RegisterOidcDialog>
<RegisterSamlDialog>
<Button variant="secondary" size="sm">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog>
</div>
)}
{providers && providers.length > 0 ? (
<div className="space-y-3">
<span className="text-sm font-medium">Registered providers</span>
<div className="grid gap-3 sm:grid-cols-2">
{providers.map((provider) => {
const isOidc = !!provider.oidcConfig;
const isSaml = !!provider.samlConfig;
return (
<Card
key={provider.id}
className="overflow-hidden bg-background"
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-base font-medium">
{provider.providerId}
</CardTitle>
<CardDescription className="text-xs">
{provider.issuer}
</CardDescription>
<div className="flex flex-wrap gap-1 mt-1">
<Badge variant="secondary" className="text-xs">
{provider.domain}
</Badge>
{isOidc && (
<Badge variant="outline" className="text-xs">
OIDC
</Badge>
)}
{isSaml && (
<Badge variant="outline" className="text-xs">
SAML
</Badge>
)}
</div>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 pt-0">
<Button
variant="ghost"
size="sm"
onClick={() =>
setDetailsProvider({
id: provider.id,
providerId: provider.providerId,
issuer: provider.issuer,
domain: provider.domain,
oidcConfig: provider.oidcConfig,
samlConfig: provider.samlConfig,
organizationId: provider.organizationId,
})
}
>
<Eye className="mr-1 size-3" />
View details
</Button>
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
type="destructive"
onClick={async () => {
try {
await deleteProvider({
providerId: provider.providerId,
});
toast.success("Provider removed");
await utils.sso.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error
? err.message
: "Failed to remove provider",
);
}
}}
>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isDeleting}
>
<Trash2 className="mr-1 size-3" />
Remove
</Button>
</DialogAction>
</CardContent>
</Card>
);
})}
</div>
</div>
) : (
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
<div className="flex flex-col items-center gap-2 max-w-[400px]">
<div className="rounded-full bg-muted p-4">
<LogIn className="size-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold">No SSO providers</h3>
<p className="text-sm text-muted-foreground">
Add an OIDC or SAML provider so users can sign in with their
organization&apos;s IdP (e.g. Okta, Azure AD).
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 justify-center">
<RegisterOidcDialog>
<Button variant="secondary">
<LogIn className="mr-2 size-4" />
Add OIDC provider
</Button>
</RegisterOidcDialog>
<RegisterSamlDialog>
<Button variant="outline">
<LogIn className="mr-2 size-4" />
Add SAML provider
</Button>
</RegisterSamlDialog>
</div>
</div>
)}
</>
)}
<Dialog
open={!!detailsProvider}
onOpenChange={(open) => !open && setDetailsProvider(null)}
>
<DialogContent className="sm:max-w-[480px]">
{detailsProvider && (
<>
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
View-only. To change settings, remove this provider and add it
again with the new values.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Provider ID
</span>
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
{detailsProvider.providerId}
</p>
</div>
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Issuer URL
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
{detailsProvider.issuer}
</p>
</div>
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Domain
</span>
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
{detailsProvider.domain}
</p>
</div>
{detailsProvider.oidcConfig && (
<>
{(() => {
const oidc = parseOidcConfig(detailsProvider.oidcConfig);
if (!oidc) return null;
return (
<>
{oidc.clientId && (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Client ID
</span>
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
{oidc.clientId}
</p>
</div>
)}
{oidc.scopes && oidc.scopes.length > 0 && (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Scopes
</span>
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
{oidc.scopes.join(" ")}
</p>
</div>
)}
</>
);
})()}
</>
)}
{detailsProvider.samlConfig && (
<>
{(() => {
const saml = parseSamlConfig(detailsProvider.samlConfig);
if (!saml?.entryPoint) return null;
return (
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Entry point
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
{saml.entryPoint}
</p>
</div>
);
})()}
</>
)}
<div className="grid gap-1">
<span className="text-xs font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</span>
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{baseURL || "{baseURL}"}/api/auth/sso/callback/
{detailsProvider.providerId}
</p>
{!baseURL && (
<p className="text-xs text-muted-foreground">
Replace {"{baseURL}"} with your Dokploy URL (e.g. https://
your-domain.com).
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDetailsProvider(null)}
>
Close
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,290 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const whitelabelSchema = z.object({
whitelabelAppName: z.string().min(1).max(100),
whitelabelLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelFaviconUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginTitle: z.string().max(200).optional(),
whitelabelLoginSubtitle: z.string().max(500).optional(),
whitelabelLoginBackgroundImageUrl: z
.union([z.string().url(), z.literal("")])
.optional(),
});
type WhitelabelFormValues = z.infer<typeof whitelabelSchema>;
export function WhitelabelSettings() {
const { data: settings, isLoading } =
api.settings.getWebServerSettings.useQuery();
const { mutateAsync: updateWhitelabel, isLoading: isSaving } =
api.settings.updateWhitelabelSettings.useMutation();
const utils = api.useUtils();
const form = useForm<WhitelabelFormValues>({
resolver: zodResolver(whitelabelSchema),
defaultValues: {
whitelabelAppName: "Dokploy",
whitelabelLogoUrl: "",
whitelabelLoginLogoUrl: "",
whitelabelFaviconUrl: "",
whitelabelLoginTitle: "",
whitelabelLoginSubtitle: "",
whitelabelLoginBackgroundImageUrl: "",
},
});
useEffect(() => {
if (settings) {
form.reset({
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? "",
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? "",
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? "",
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? "",
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? "",
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? "",
});
}
}, [settings, form]);
const onSubmit = async (values: WhitelabelFormValues) => {
try {
await updateWhitelabel({
whitelabelAppName: values.whitelabelAppName || null,
whitelabelLogoUrl: values.whitelabelLogoUrl || undefined,
whitelabelLoginLogoUrl: values.whitelabelLoginLogoUrl || undefined,
whitelabelFaviconUrl: values.whitelabelFaviconUrl || undefined,
whitelabelLoginTitle: values.whitelabelLoginTitle || null,
whitelabelLoginSubtitle: values.whitelabelLoginSubtitle || null,
whitelabelLoginBackgroundImageUrl:
values.whitelabelLoginBackgroundImageUrl || undefined,
});
toast.success("Whitelabel settings saved");
utils.settings.getWebServerSettings.invalidate();
utils.settings.getWhitelabelSettings.invalidate();
} catch (e) {
toast.error("Failed to save whitelabel settings");
}
};
if (isLoading) {
return (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Loading whitelabel settings...
</span>
</div>
);
}
return (
<div className="flex flex-col gap-4 rounded-lg ">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Palette className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Whitelabeling</CardTitle>
</div>
<CardDescription>
Customize the application name, logos, and login page for your brand.
Leave URLs empty to use defaults.
</CardDescription>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<div className="space-y-4 pt-2 border-t">
<div>
<h3 className="text-sm font-medium">Brand</h3>
<p className="text-sm text-muted-foreground">
Application name and main logo (sidebar, header).
</p>
</div>
<FormField
control={form.control}
name="whitelabelAppName"
render={({ field }) => (
<FormItem>
<FormLabel>Application name</FormLabel>
<FormControl>
<Input
placeholder="Dokploy"
{...field}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo shown in the sidebar and header.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelFaviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon.ico"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4 pt-6 border-t">
<div>
<h3 className="text-sm font-medium">Login page</h3>
<p className="text-sm text-muted-foreground">
Customize the sign-in and registration screens.
</p>
</div>
<FormField
control={form.control}
name="whitelabelLoginLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo on the login and register pages. Falls back to the main
logo if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login title</FormLabel>
<FormControl>
<Input
placeholder="Sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginSubtitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login subtitle</FormLabel>
<FormControl>
<Input
placeholder="Enter your email and password to sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginBackgroundImageUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login background image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/background.jpg"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Optional background image for the login page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS "ai" (
"aiId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"apiUrl" text NOT NULL,
"apiKey" text NOT NULL,
"model" text NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL,
"adminId" text NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "ai" ADD CONSTRAINT "ai_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,12 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'pushover' BEFORE 'custom';--> statement-breakpoint
CREATE TABLE "pushover" (
"pushoverId" text PRIMARY KEY NOT NULL,
"userKey" text NOT NULL,
"apiToken" text NOT NULL,
"priority" integer DEFAULT 0 NOT NULL,
"retry" integer,
"expire" integer
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "pushoverId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_pushoverId_pushover_pushoverId_fk" FOREIGN KEY ("pushoverId") REFERENCES "public"."pushover"("pushoverId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "bitbucketRepositorySlug" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "bitbucketRepositorySlug" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "licenseKey" text;

View File

@@ -0,0 +1,13 @@
CREATE TABLE "sso_provider" (
"id" text PRIMARY KEY NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"user_id" text,
"provider_id" text NOT NULL,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> 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 cascade ON UPDATE no action;

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -946,6 +946,48 @@
"when": 1767871040249,
"tag": "0134_strong_hercules",
"breakpoints": true
},
{
"idx": 135,
"version": "7",
"when": 1768271617042,
"tag": "0135_illegal_magik",
"breakpoints": true
},
{
"idx": 136,
"version": "7",
"when": 1769580434296,
"tag": "0136_tidy_puff_adder",
"breakpoints": true
},
{
"idx": 137,
"version": "7",
"when": 1769616589728,
"tag": "0137_naive_power_pack",
"breakpoints": true
},
{
"idx": 138,
"version": "7",
"when": 1769745328628,
"tag": "0138_common_mathemanic",
"breakpoints": true
},
{
"idx": 139,
"version": "7",
"when": 1769746948088,
"tag": "0139_smiling_havok",
"breakpoints": true
},
{
"idx": 140,
"version": "7",
"when": 1769854977685,
"tag": "0140_great_lightspeed",
"breakpoints": true
}
]
}

View File

@@ -1,3 +1,4 @@
import { ssoClient } from "@better-auth/sso/client";
import {
adminClient,
apiKeyClient,
@@ -13,6 +14,7 @@ export const authClient = createAuthClient({
organizationClient(),
twoFactorClient(),
apiKeyClient(),
ssoClient(),
adminClient(),
inferAdditionalFields({
user: {

View File

@@ -1,10 +1,9 @@
import { dbUrl } from "@dokploy/server/db";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const sql = postgres(dbUrl, { max: 1 });
const db = drizzle(sql);
await migrate(db, { migrationsFolder: "drizzle" })

View File

@@ -19,6 +19,32 @@ const nextConfig = {
locales: ["en"],
defaultLocale: "en",
},
async headers() {
return [
{
// Apply security headers to all routes
source: "/:path*",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'none'",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
],
},
];
},
};
export default nextConfig;

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.26.4",
"version": "v0.26.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -37,6 +37,7 @@
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"@better-auth/sso": "1.4.18",
"@ai-sdk/anthropic": "^2.0.5",
"@ai-sdk/azure": "^2.0.16",
"@ai-sdk/cohere": "^2.0.4",
@@ -94,7 +95,7 @@
"ai": "^5.0.17",
"ai-sdk-ollama": "^0.5.1",
"bcrypt": "5.1.1",
"better-auth": "v1.2.8-beta.7",
"better-auth": "1.4.18",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",
@@ -196,10 +197,5 @@
"*": [
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
}
}

View File

@@ -195,7 +195,9 @@ export default async function handler(
const commitedPaths = await extractCommitedPaths(
req.body,
application.bitbucket,
application.bitbucketRepository || "",
application.bitbucketRepositorySlug ||
application.bitbucketRepository ||
"",
);
const shouldDeployPaths = shouldDeploy(

View File

@@ -100,7 +100,9 @@ export default async function handler(
const commitedPaths = await extractCommitedPaths(
req.body,
composeResult.bitbucket,
composeResult.bitbucketRepository || "",
composeResult.bitbucketRepositorySlug ||
composeResult.bitbucketRepository ||
"",
);
const shouldDeployPaths = shouldDeploy(

View File

@@ -1624,9 +1624,39 @@ export async function getServerSideProps(
projectId: params.projectId,
});
await helpers.environment.one.fetch({
environmentId: params.environmentId,
});
// Try to fetch the requested environment
try {
await helpers.environment.one.fetch({
environmentId: params.environmentId,
});
} catch (error) {
// If user doesn't have access to requested environment, redirect to accessible one
const accessibleEnvironments =
await helpers.environment.byProjectId.fetch({
projectId: params.projectId,
});
if (accessibleEnvironments.length > 0) {
// Try to find default, otherwise use first accessible
const targetEnv =
accessibleEnvironments.find((env) => env.isDefault) ||
accessibleEnvironments[0];
return {
redirect: {
permanent: false,
destination: `/dashboard/project/${params.projectId}/environment/${targetEnv.environmentId}`,
},
};
}
// No accessible environments, redirect to home
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
await helpers.environment.byProjectId.fetch({
projectId: params.projectId,

View File

@@ -108,6 +108,7 @@ const Service = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",

View File

@@ -97,6 +97,7 @@ const Service = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",

View File

@@ -79,6 +79,7 @@ const Mariadb = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",

View File

@@ -78,6 +78,7 @@ const Mongo = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",

View File

@@ -77,6 +77,7 @@ const MySql = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",

View File

@@ -77,6 +77,7 @@ const Postgresql = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",

View File

@@ -77,6 +77,7 @@ const Redis = (
{ name: "Projects", href: "/dashboard/projects" },
{
name: data?.environment?.project?.name || "",
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
},
{
name: data?.environment?.name || "",

View File

@@ -0,0 +1,84 @@
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { LicenseKeySettings } from "@/components/proprietary/license-keys/license-key";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<LicenseKeySettings />
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="License">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -0,0 +1,84 @@
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { SSOSettings } from "@/components/proprietary/sso/sso-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Enterprise SSO",
description:
"Single sign-on (SSO) with OIDC and SAML is part of Dokploy Enterprise. Add a valid license to configure it.",
ctaLabel: "Go to License",
}}
>
<SSOSettings />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="SSO">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -0,0 +1,84 @@
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 { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { WhitelabelSettings } from "@/components/proprietary/whitelabelling/whitelabel-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Enterprise Whitelabeling",
description:
"Whitelabeling is part of Dokploy Enterprise. Add a valid license to customize logos, app name, and login page.",
ctaLabel: "Go to License",
}}
>
<WhitelabelSettings />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: ctx.res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -10,6 +10,9 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
import { SignInWithGoogle } from "@/components/proprietary/auth/sign-in-with-google";
import { SignInWithSSO } from "@/components/proprietary/sso/sign-in-with-sso";
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
@@ -37,6 +40,7 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const LoginSchema = z.object({
email: z.string().email(),
@@ -54,6 +58,8 @@ interface Props {
}
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -62,8 +68,6 @@ export default function Home({ IS_CLOUD }: Props) {
const [twoFactorCode, setTwoFactorCode] = useState("");
const [isBackupCodeModalOpen, setIsBackupCodeModalOpen] = useState(false);
const [backupCode, setBackupCode] = useState("");
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const loginForm = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
defaultValues: {
@@ -161,56 +165,75 @@ export default function Home({ IS_CLOUD }: Props) {
}
};
const handleGithubSignIn = async () => {
setIsGithubLoading(true);
try {
const { error } = await authClient.signIn.social({
provider: "github",
});
const loginContent = (
<>
{IS_CLOUD && <SignInWithGithub />}
{IS_CLOUD && <SignInWithGoogle />}
<Form {...loginForm}>
<form
onSubmit={loginForm.handleSubmit(onSubmit)}
className="space-y-4"
id="login-form"
>
<FormField
control={loginForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="w-full" type="submit" isLoading={isLoginLoading}>
Login
</Button>
</form>
</Form>
</>
);
if (error) {
toast.error(error.message);
return;
}
} catch (error) {
toast.error("An error occurred while signing in with GitHub", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsGithubLoading(false);
}
};
const loginLogoUrl =
whitelabel?.whitelabelLoginLogoUrl ?? whitelabel?.whitelabelLogoUrl;
const loginTitle = whitelabel?.whitelabelLoginTitle ?? "Sign in";
const loginSubtitle =
whitelabel?.whitelabelLoginSubtitle ??
"Enter your email and password to sign in";
const handleGoogleSignIn = async () => {
setIsGoogleLoading(true);
try {
const { error } = await authClient.signIn.social({
provider: "google",
});
if (error) {
toast.error(error.message);
return;
}
} catch (error) {
toast.error("An error occurred while signing in with Google", {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsGoogleLoading(false);
}
};
return (
<>
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
<div className="flex flex-row items-center justify-center gap-2">
<Logo className="size-12" />
Sign in
<Logo
className="size-12"
logoUrl={loginLogoUrl ?? undefined}
/>
{loginTitle}
</div>
</h1>
<p className="text-sm text-muted-foreground">
Enter your email and password to sign in
{loginSubtitle}
</p>
</div>
{error && (
@@ -221,97 +244,11 @@ export default function Home({ IS_CLOUD }: Props) {
<CardContent className="p-0">
{!isTwoFactor ? (
<>
{IS_CLOUD && (
<Button
variant="outline"
type="button"
className="w-full mb-4"
onClick={handleGithubSignIn}
isLoading={isGithubLoading}
>
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
<path
fill="currentColor"
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
/>
</svg>
Sign in with GitHub
</Button>
{showSignInWithSSO ? (
<SignInWithSSO>{loginContent}</SignInWithSSO>
) : (
loginContent
)}
{IS_CLOUD && (
<Button
variant="outline"
type="button"
className="w-full mb-4"
onClick={handleGoogleSignIn}
isLoading={isGoogleLoading}
>
<svg viewBox="0 0 24 24" className="mr-2 size-4">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</Button>
)}
<Form {...loginForm}>
<form
onSubmit={loginForm.handleSubmit(onSubmit)}
className="space-y-4"
id="login-form"
>
<FormField
control={loginForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={loginForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
className="w-full"
type="submit"
isLoading={isLoginLoading}
>
Login
</Button>
</form>
</Form>
</>
) : (
<>

View File

@@ -9,6 +9,8 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
import { SignInWithGoogle } from "@/components/proprietary/auth/sign-in-with-google";
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
@@ -152,6 +154,17 @@ const Register = ({ isCloud }: Props) => {
</AlertBlock>
)}
<CardContent className="p-0">
{isCloud && (
<div className="flex flex-col">
<SignInWithGithub />
<SignInWithGoogle />
</div>
)}
{isCloud && (
<p className="mb-4 text-center text-xs text-muted-foreground">
Or register with email
</p>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -0,0 +1,18 @@
# Proprietary Features
This directory contains all proprietary functionality of Dokploy.
## Purpose
This folder will house all **paid features** and premium functionality that are not part of the open source code.
## License
The code in this directory is under Dokploy's proprietary license. See [LICENSE_PROPRIETARY.md](../../../LICENSE_PROPRIETARY.md) for more details.
## Contact
If you want to learn more about our paid features or have any questions, please contact us at:
- Email: [sales@dokploy.com](mailto:sales@dokploy.com)
- Contact Form: [https://dokploy.com/contact](https://dokploy.com/contact)

View File

@@ -22,6 +22,8 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
@@ -82,6 +84,8 @@ export const appRouter = createTRPCRouter({
swarm: swarmRouter,
ai: aiRouter,
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,

View File

@@ -469,6 +469,7 @@ export const applicationRouter = createTRPCRouter({
}
await updateApplication(input.applicationId, {
bitbucketRepository: input.bitbucketRepository,
bitbucketRepositorySlug: input.bitbucketRepositorySlug,
bitbucketOwner: input.bitbucketOwner,
bitbucketBranch: input.bitbucketBranch,
bitbucketBuildPath: input.bitbucketBuildPath,

View File

@@ -5,6 +5,7 @@ import {
createGotifyNotification,
createLarkNotification,
createNtfyNotification,
createPushoverNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
@@ -17,6 +18,7 @@ import {
sendGotifyNotification,
sendLarkNotification,
sendNtfyNotification,
sendPushoverNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTelegramNotification,
@@ -26,6 +28,7 @@ import {
updateGotifyNotification,
updateLarkNotification,
updateNtfyNotification,
updatePushoverNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -46,6 +49,7 @@ import {
apiCreateGotify,
apiCreateLark,
apiCreateNtfy,
apiCreatePushover,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
@@ -55,6 +59,7 @@ import {
apiTestGotifyConnection,
apiTestLarkConnection,
apiTestNtfyConnection,
apiTestPushoverConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateCustom,
@@ -63,6 +68,7 @@ import {
apiUpdateGotify,
apiUpdateLark,
apiUpdateNtfy,
apiUpdatePushover,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -342,6 +348,7 @@ export const notificationRouter = createTRPCRouter({
ntfy: true,
custom: true,
lark: true,
pushover: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -634,6 +641,62 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createPushover: adminProcedure
.input(apiCreatePushover)
.mutation(async ({ input, ctx }) => {
try {
return await createPushoverNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updatePushover: adminProcedure
.input(apiUpdatePushover)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updatePushoverNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testPushoverConnection: adminProcedure
.input(apiTestPushoverConnection)
.mutation(async ({ input }) => {
try {
await sendPushoverNotification(
input,
"Test Notification",
"Hi, From Dokploy 👋",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),

View File

@@ -0,0 +1,206 @@
import { user } from "@dokploy/server/db/schema";
import { validateLicenseKey } from "@dokploy/server/index";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { adminProcedure, createTRPCRouter } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
activateLicenseKey,
deactivateLicenseKey,
} from "@/server/utils/enterprise";
export const licenseKeyRouter = createTRPCRouter({
activate: adminProcedure
.input(z.object({ licenseKey: z.string() }))
.mutation(async ({ input, ctx }) => {
try {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (!currentUser.enableEnterpriseFeatures) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Please activate enterprise features to activate license key",
});
}
await activateLicenseKey(input.licenseKey);
await db
.update(user)
.set({
licenseKey: input.licenseKey,
isValidEnterpriseLicense: true,
})
.where(eq(user.id, currentUserId));
return { success: true };
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to activate license key",
cause: error,
});
}
}),
validate: adminProcedure.mutation(async ({ ctx }) => {
try {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (!currentUser.licenseKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No license key found",
});
}
if (!currentUser.enableEnterpriseFeatures) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Please activate enterprise features to validate license key",
});
}
const valid = await validateLicenseKey(currentUser.licenseKey);
if (valid) {
await db
.update(user)
.set({ isValidEnterpriseLicense: true })
.where(eq(user.id, currentUserId));
}
return valid;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to validate license key",
});
}
}),
deactivate: adminProcedure.mutation(async ({ ctx }) => {
try {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
if (!currentUser.licenseKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No license key found",
});
}
await deactivateLicenseKey(currentUser.licenseKey);
await db
.update(user)
.set({
licenseKey: null,
isValidEnterpriseLicense: false,
})
.where(eq(user.id, currentUserId));
return { success: true };
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to deactivate license key",
});
}
}),
getEnterpriseSettings: adminProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return {
enableEnterpriseFeatures: !!currentUser.enableEnterpriseFeatures,
licenseKey: currentUser.licenseKey ?? "",
};
}),
haveValidLicenseKey: adminProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
return !!(
currentUser?.enableEnterpriseFeatures &&
currentUser?.isValidEnterpriseLicense
);
}),
updateEnterpriseSettings: adminProcedure
.input(
z.object({
enableEnterpriseFeatures: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
const currentUserId = ctx.user.id;
if (input.enableEnterpriseFeatures === undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "enableEnterpriseFeatures must be provided",
});
}
await db
.update(user)
.set({
enableEnterpriseFeatures: input.enableEnterpriseFeatures,
})
.where(eq(user.id, currentUserId));
return true;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to update enterprise settings",
});
}
}),
});

View File

@@ -0,0 +1,113 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import {
createTRPCRouter,
enterpriseProcedure,
publicProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
function requestToHeaders(req: {
headers?: Record<string, string | string[] | undefined>;
}): Headers {
const headers = new Headers();
if (req?.headers) {
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined && key.toLowerCase() !== "host") {
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
}
}
return headers;
}
export const ssoRouter = createTRPCRouter({
showSignInWithSSO: publicProcedure.query(async () => {
if (IS_CLOUD) {
return true;
}
const owner = await db.query.member.findFirst({
where: eq(member.role, "owner"),
with: {
user: {
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
},
},
orderBy: [asc(member.createdAt)],
});
if (!owner) {
return false;
}
return (
owner.user.enableEnterpriseFeatures && owner.user.isValidEnterpriseLicense
);
}),
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),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
});
return providers;
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const [deleted] = await db
.delete(ssoProvider)
.where(
and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
)
.returning({ id: ssoProvider.id });
if (!deleted) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to delete it",
});
}
return { success: true };
}),
register: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const organizationId = ctx.session.activeOrganizationId;
const result = await auth.registerSSOProvider({
body: {
...input,
organizationId,
},
headers: requestToHeaders(ctx.req),
});
console.log(result);
return { success: true };
}),
});

View File

@@ -63,6 +63,7 @@ import {
apiServerSchema,
apiTraefikConfig,
apiUpdateDockerCleanup,
apiUpdateWhitelabel,
projects,
server,
} from "@/server/db/schema";
@@ -72,6 +73,7 @@ import { appRouter } from "../root";
import {
adminProcedure,
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
publicProcedure,
} from "../trpc";
@@ -84,6 +86,57 @@ export const settingsRouter = createTRPCRouter({
const settings = await getWebServerSettings();
return settings;
}),
getWhitelabelSettings: publicProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
if (!settings) return null;
return {
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? null,
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? null,
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? null,
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? null,
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? null,
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? null,
};
}),
updateWhitelabelSettings: enterpriseProcedure
.input(apiUpdateWhitelabel)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return null;
}
const updates: Record<string, unknown> = {};
if (input.whitelabelAppName !== undefined)
updates.whitelabelAppName = input.whitelabelAppName;
if (input.whitelabelLogoUrl !== undefined)
updates.whitelabelLogoUrl =
input.whitelabelLogoUrl === "" ? null : input.whitelabelLogoUrl;
if (input.whitelabelLoginLogoUrl !== undefined)
updates.whitelabelLoginLogoUrl =
input.whitelabelLoginLogoUrl === ""
? null
: input.whitelabelLoginLogoUrl;
if (input.whitelabelFaviconUrl !== undefined)
updates.whitelabelFaviconUrl =
input.whitelabelFaviconUrl === ""
? null
: input.whitelabelFaviconUrl;
if (input.whitelabelLoginTitle !== undefined)
updates.whitelabelLoginTitle = input.whitelabelLoginTitle;
if (input.whitelabelLoginSubtitle !== undefined)
updates.whitelabelLoginSubtitle = input.whitelabelLoginSubtitle;
if (input.whitelabelLoginBackgroundImageUrl !== undefined)
updates.whitelabelLoginBackgroundImageUrl =
input.whitelabelLoginBackgroundImageUrl === ""
? null
: input.whitelabelLoginBackgroundImageUrl;
const updated = await updateWebServerSettings(updates as any);
return updated;
}),
reloadServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;

View File

@@ -75,13 +75,12 @@ export const stripeRouter = createTRPCRouter({
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: items,
...(stripeCustomerId && {
customer: stripeCustomerId,
}),
...(stripeCustomerId
? { customer: stripeCustomerId }
: { customer_email: owner.email }),
metadata: {
adminId: owner.id,
},
customer_email: owner.email,
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,

View File

@@ -7,8 +7,10 @@
* need to use are documented accordingly near the end.
*/
import { user as userSchema } from "@dokploy/server/db/schema";
import { validateRequest } from "@dokploy/server/lib/auth";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { eq } from "drizzle-orm";
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import {
@@ -217,3 +219,43 @@ export const adminProcedure = t.procedure.use(({ ctx, next }) => {
},
});
});
/**
* Requires admin/owner role AND enterprise enabled with a license key in DB.
* Does NOT call the license server on every request; full validation (haveValidLicenseKey)
* is used in the UI gate and when activating/validating keys.
*/
export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const currentUser = await ctx.db.query.user.findFirst({
where: eq(userSchema.id, ctx.user.id),
columns: {
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
if (
!currentUser?.enableEnterpriseFeatures ||
!currentUser.isValidEnterpriseLicense
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",
});
}
return next({
ctx: {
session: ctx.session,
user: ctx.user,
},
});
});

View File

@@ -1,10 +1,11 @@
import { dbUrl } from "@dokploy/server/db";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./server/db/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
url: dbUrl,
},
out: "drizzle",
migrations: {

View File

@@ -1,3 +1,4 @@
import { dbUrl } from "@dokploy/server/db/constants";
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
@@ -6,10 +7,6 @@ declare global {
var db: PostgresJsDatabase<typeof schema> | undefined;
}
const dbUrl =
process.env.DATABASE_URL ||
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
export let db: PostgresJsDatabase<typeof schema>;
if (process.env.NODE_ENV === "production") {
db = drizzle(postgres(dbUrl!), {

View File

@@ -1,10 +1,9 @@
import { dbUrl } from "@dokploy/server/db";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const sql = postgres(dbUrl, { max: 1 });
const db = drizzle(sql);
export const migration = async () =>

View File

@@ -1,11 +1,10 @@
import { dbUrl } from "@dokploy/server/db";
import { sql } from "drizzle-orm";
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
const pg = postgres(connectionString, { max: 1 });
const pg = postgres(dbUrl, { max: 1 });
const db = drizzle(pg);
const clearDb = async (): Promise<void> => {

View File

@@ -6,6 +6,7 @@ import {
IS_CLOUD,
initCancelDeployments,
initCronJobs,
initEnterpriseBackupCronJobs,
initializeNetwork,
initSchedules,
initVolumeBackupsCronJobs,
@@ -15,6 +16,7 @@ import {
import { config } from "dotenv";
import next from "next";
import { migration } from "@/server/db/migration";
import packageInfo from "../package.json";
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
@@ -33,13 +35,14 @@ if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
setupDirectories();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
console.log("✅ Critical initialization complete");
console.log("✅ initialization complete");
}
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
const handle = app.getRequestHandler();
void app.prepare().then(async () => {
try {
console.log("Running DokployVersion: ", packageInfo.version);
const server = http.createServer((req, res) => {
handle(req, res);
});
@@ -71,6 +74,8 @@ void app.prepare().then(async () => {
server.listen(PORT, HOST);
console.log(`Server Started on: http://${HOST}:${PORT}`);
await initEnterpriseBackupCronJobs();
if (!IS_CLOUD) {
console.log("Starting Deployment Worker");
const { deploymentWorker } = await import("./queues/deployments-queue");

View File

@@ -0,0 +1,83 @@
import { getPublicIpWithFallback } from "@dokploy/server/index";
const LICENSE_KEY_URL = process.env.LICENSE_KEY_URL || "http://localhost:4002";
export const validateLicenseKey = async (licenseKey: string) => {
try {
const ip = await getPublicIpWithFallback();
const result = await fetch(`${LICENSE_KEY_URL}/licenses/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, ip }),
});
if (!result.ok) {
const errorData = await result.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to validate license key");
}
const data = await result.json();
return data.valid;
} catch (error) {
console.error(
error instanceof Error ? error.message : "Failed to validate license key",
);
throw error;
}
};
export const activateLicenseKey = async (licenseKey: string) => {
try {
const ip = await getPublicIpWithFallback();
const result = await fetch(`${LICENSE_KEY_URL}/licenses/activate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, ip }),
});
if (!result.ok) {
const errorData = await result.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to activate license key");
}
const data = await result.json();
return data;
} catch (error) {
console.error(
error instanceof Error ? error.message : "Failed to activate license key",
);
throw error;
}
};
export const deactivateLicenseKey = async (licenseKey: string) => {
try {
const ip = await getPublicIpWithFallback();
const result = await fetch(`${LICENSE_KEY_URL}/licenses/deactivate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, ip }),
});
if (!result.ok) {
const errorData = await result.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to deactivate license key");
}
const data = await result.json();
return data;
} catch (error) {
console.error(
error instanceof Error
? error.message
: "Failed to deactivate license key",
);
throw error;
}
};

View File

@@ -1,9 +1,9 @@
import type http from "node:http";
import { findServerById, validateRequest } from "@dokploy/server";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { getShell } from "./utils";
import { getShell, isValidContainerId } from "./utils";
export const setupDockerContainerLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -42,6 +42,12 @@ export const setupDockerContainerLogsWebSocketServer = (
return;
}
// Security: Validate containerId to prevent command injection
if (!isValidContainerId(containerId)) {
ws.close(4000, "Invalid container ID format");
return;
}
if (!user || !session) {
ws.close();
return;
@@ -111,6 +117,11 @@ export const setupDockerContainerLogsWebSocketServer = (
client.end();
});
} else {
if (IS_CLOUD) {
ws.send("This feature is not available in the cloud version.");
ws.close();
return;
}
const shell = getShell();
const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps ${
runType === "swarm" ? "--raw" : ""

View File

@@ -1,9 +1,9 @@
import type http from "node:http";
import { findServerById, validateRequest } from "@dokploy/server";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { getShell } from "./utils";
import { isValidContainerId, isValidShell } from "./utils";
export const setupDockerContainerTerminalWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -35,10 +35,25 @@ export const setupDockerContainerTerminalWebSocketServer = (
const { user, session } = await validateRequest(req);
if (!containerId) {
ws.close(4000, "containerId no provided");
ws.close(4000, "containerId not provided");
return;
}
// Security: Validate containerId to prevent command injection
if (!isValidContainerId(containerId)) {
ws.close(4000, "Invalid container ID format");
return;
}
// Security: Validate shell to prevent command injection
if (activeWay && !isValidShell(activeWay)) {
ws.close(4000, "Invalid shell specified");
return;
}
// Default to 'sh' if no shell specified
const shell = activeWay || "sh";
if (!user || !session) {
ws.close();
return;
@@ -54,55 +69,61 @@ export const setupDockerContainerTerminalWebSocketServer = (
let _stderr = "";
conn
.once("ready", () => {
conn.exec(
`docker exec -it -w / ${containerId} ${activeWay}`,
{ pty: true },
(err, stream) => {
if (err) {
console.error("SSH exec error:", err);
ws.close();
// Use array-style arguments to prevent shell injection
const dockerCommand = [
"docker",
"exec",
"-it",
"-w",
"/",
containerId,
shell,
].join(" ");
conn.exec(dockerCommand, { pty: true }, (err, stream) => {
if (err) {
console.error("SSH exec error:", err);
ws.close();
conn.end();
return;
}
stream
.on("close", (code: number, _signal: string) => {
ws.send(`\nContainer closed with code: ${code}\n`);
conn.end();
return;
}
})
.on("data", (data: string) => {
_stdout += data.toString();
ws.send(data.toString());
})
.stderr.on("data", (data) => {
_stderr += data.toString();
ws.send(data.toString());
console.error("Error: ", data.toString());
});
stream
.on("close", (code: number, _signal: string) => {
ws.send(`\nContainer closed with code: ${code}\n`);
conn.end();
})
.on("data", (data: string) => {
_stdout += data.toString();
ws.send(data.toString());
})
.stderr.on("data", (data) => {
_stderr += data.toString();
ws.send(data.toString());
console.error("Error: ", data.toString());
});
ws.on("message", (message) => {
try {
let command: string | Buffer[] | Buffer | ArrayBuffer;
if (Buffer.isBuffer(message)) {
command = message.toString("utf8");
} else {
command = message;
}
stream.write(command.toString());
} catch (error) {
// @ts-ignore
const errorMessage = error?.message as unknown as string;
ws.send(errorMessage);
ws.on("message", (message) => {
try {
let command: string | Buffer[] | Buffer | ArrayBuffer;
if (Buffer.isBuffer(message)) {
command = message.toString("utf8");
} else {
command = message;
}
});
stream.write(command.toString());
} catch (error) {
// @ts-ignore
const errorMessage = error?.message as unknown as string;
ws.send(errorMessage);
}
});
ws.on("close", () => {
stream.end();
// Ensure SSH connection is closed when WebSocket closes
conn.end();
});
},
);
ws.on("close", () => {
stream.end();
// Ensure SSH connection is closed when WebSocket closes
conn.end();
});
});
})
.on("error", (err) => {
console.error("SSH connection error:", err);
@@ -119,10 +140,14 @@ export const setupDockerContainerTerminalWebSocketServer = (
privateKey: server.sshKey?.privateKey,
});
} else {
const shell = getShell();
if (IS_CLOUD) {
ws.send("This feature is not available in the cloud version.");
ws.close();
return;
}
const ptyProcess = spawn(
shell,
["-c", `docker exec -it -w / ${containerId} ${activeWay}`],
"docker",
["exec", "-it", "-w", "/", containerId, shell],
{},
);

View File

@@ -4,6 +4,7 @@ import {
execAsync,
getHostSystemStats,
getLastAdvancedStatsFile,
IS_CLOUD,
recordAdvancedStats,
validateRequest,
} from "@dokploy/server";
@@ -32,6 +33,12 @@ export const setupDockerStatsMonitoringSocketServer = (
wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
if (IS_CLOUD) {
ws.send("This feature is not available in the cloud version.");
ws.close();
return;
}
const appName = url.searchParams.get("appName");
const appType = (url.searchParams.get("appType") || "application") as
| "application"

View File

@@ -1,8 +1,9 @@
import { spawn } from "node:child_process";
import type http from "node:http";
import { findServerById, validateRequest } from "@dokploy/server";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { readValidDirectory } from "./utils";
export const setupDeploymentLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -40,6 +41,11 @@ export const setupDeploymentLogsWebSocketServer = (
return;
}
if (!readValidDirectory(logPath)) {
ws.close(4000, "Invalid log path");
return;
}
if (!user || !session) {
ws.close();
return;
@@ -108,6 +114,11 @@ export const setupDeploymentLogsWebSocketServer = (
}
});
} else {
if (IS_CLOUD) {
ws.send("This feature is not available in the cloud version.");
ws.close();
return;
}
tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]);
const stdout = tailProcess.stdout;

View File

@@ -97,7 +97,12 @@ export const setupTerminalWebSocketServer = (
const isLocalServer = serverId === "local";
if (isLocalServer && !IS_CLOUD) {
if (isLocalServer) {
if (IS_CLOUD) {
ws.send("This feature is not available in the cloud version.");
ws.close();
return;
}
const port = Number(url.searchParams.get("port"));
const username = url.searchParams.get("username");

View File

@@ -1,9 +1,52 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execAsync, paths } from "@dokploy/server";
import { execAsync, IS_CLOUD, paths } from "@dokploy/server";
/**
* Validates that the container ID matches Docker's expected format.
* Docker container IDs are 64-character hex strings (or 12-char short form).
* Also allows container names: alphanumeric, underscores, hyphens, and dots.
*/
export const isValidContainerId = (id: string): boolean => {
// Match full ID (64 hex chars), short ID (12 hex chars), or container name
const hexPattern = /^[a-f0-9]{12,64}$/i;
const namePattern = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
};
/**
* Validates that the shell is one of the allowed shells.
*/
export const isValidShell = (shell: string): boolean => {
const allowedShells = [
"sh",
"bash",
"zsh",
"ash",
"/bin/sh",
"/bin/bash",
"/bin/zsh",
"/bin/ash",
];
return allowedShells.includes(shell);
};
export const readValidDirectory = (directory: string) => {
const { BASE_PATH } = paths();
const resolvedBase = path.resolve(BASE_PATH);
const resolvedDir = path.resolve(directory);
return (
resolvedDir === resolvedBase ||
resolvedDir.startsWith(resolvedBase + path.sep)
);
};
export const getShell = () => {
if (IS_CLOUD) {
return "NO_AVAILABLE";
}
switch (os.platform()) {
case "win32":
return "powershell.exe";

View File

@@ -22,7 +22,7 @@ import {
await initializeNetwork();
createDefaultTraefikConfig();
createDefaultServerTraefikConfig();
await execAsync("docker pull traefik:v3.6.1");
await execAsync("docker pull traefik:v3.6.7");
await initializeStandaloneTraefik();
await initializeRedis();
await initializePostgres();

View File

@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!**/.docker",

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