Compare commits

..

164 Commits

Author SHA1 Message Date
Mauricio Siu
ca2efc5c68 fix(enterprise): update LICENSE_KEY_URL for production environment
- Changed the LICENSE_KEY_URL from "https://api-license-key.dokploy.com" to "https://licenses.dokploy.com" to reflect the correct production endpoint.
2026-02-05 02:18:24 -06:00
Mauricio Siu
dfcb422294 refactor(enterprise): consolidate LICENSE_KEY_URL handling and improve license validation logic
- Moved LICENSE_KEY_URL definition to a centralized location for better maintainability.
- Updated license validation function to utilize the new LICENSE_KEY_URL import, enhancing clarity and consistency in API calls.
2026-02-05 02:15:59 -06:00
autofix-ci[bot]
47470e2343 [autofix.ci] apply automated fixes 2026-02-05 08:05:36 +00:00
Mauricio Siu
bac9dd5c31 chore(deps): update @types/node version across multiple packages
- Upgraded @types/node from version ^18.19.104 to ^20.16.0 in package.json files for apps/api, apps/dokploy, apps/schedules, and packages/server.
- Adjusted pnpm-lock.yaml to reflect the updated @types/node version across all relevant dependencies.
- Added a new setup file for mock database interactions in the dokploy app to enhance testing capabilities.
2026-02-05 02:05:07 -06:00
Mauricio Siu
65dab84e7f chore(deps): upgrade drizzle-orm and better-auth utils versions in package.json and pnpm-lock.yaml
- Updated drizzle-orm from version ^0.39.3 to ^0.41.0 for enhanced performance and features.
- Upgraded @better-auth/utils from version 0.2.4 to 0.3.0 to incorporate the latest improvements.
- Adjusted pnpm-lock.yaml to reflect these dependency updates.
2026-02-05 01:55:58 -06:00
autofix-ci[bot]
3a0da19ea8 [autofix.ci] apply automated fixes 2026-02-05 07:54:52 +00:00
Mauricio Siu
5e460e6b4f chore(deps): update drizzle-orm and drizzle-kit versions in package.json and pnpm-lock.yaml
- Upgraded drizzle-orm from version ^0.39.3 to ^0.41.0 for improved functionality and performance.
- Updated drizzle-kit from version ^0.30.6 to ^0.31.4 to ensure compatibility with the latest drizzle-orm version.
- Adjusted related dependencies in pnpm-lock.yaml to reflect these changes.
2026-02-05 01:54:24 -06:00
Mauricio Siu
9299f04f74 chore(deps): update Vitest version and related dependencies in pnpm-lock.yaml and package.json
- Upgraded Vitest from version 1.6.1 to 4.0.18 to leverage new features and improvements.
- Updated dependency versions in pnpm-lock.yaml to ensure compatibility with the latest Vitest version.
2026-02-05 01:45:44 -06:00
Mauricio Siu
2746133252 delete(tests): remove mock database setup file and update Vitest configuration
- Deleted the mock database setup file to streamline the test environment.
- Updated the Vitest configuration to remove the reference to the deleted setup file, enhancing clarity in test setup.
2026-02-05 01:17:04 -06:00
Mauricio Siu
bde192c1e7 feat(admin): handle empty member list in trusted origins retrieval
- Added a check to return an empty array if no members are found, improving the robustness of the `getTrustedOrigins` function.
2026-02-05 01:13:25 -06:00
Mauricio Siu
99646f887b feat(tests): enhance mock database with member methods for testing
- Added mock implementations for `member.findFirst` and `member.findMany` methods in the mock database setup.
- This enhancement improves the test environment by allowing more comprehensive simulation of member-related database interactions.
2026-02-05 00:59:07 -06:00
Mauricio Siu
542ccc4479 feat(sso): enhance SSO provider management and trusted origins handling
- Added logic to retrieve and delete SSO providers, ensuring proper permission checks and error handling.
- Updated user trusted origins when adding or removing SSO providers, maintaining accurate origin lists.
- Refactored trusted origins retrieval to improve clarity and efficiency in the authentication process.
- Introduced utility functions for normalizing trusted origins and converting request headers.
2026-02-05 00:55:17 -06:00
Mauricio Siu
9910c0e602 feat(db): add sso_provider table and update user schema
- Created a new table `sso_provider` with relevant fields and constraints.
- Added new columns to the `user` table: `enableEnterpriseFeatures`, `licenseKey`, `isValidEnterpriseLicense`, and `trustedOrigins`.
- Established foreign key relationships for `user_id` and `organization_id` in the `sso_provider` table.
2026-02-05 00:50:12 -06:00
Mauricio Siu
4f0d707905 delete: remove obsolete SQL migration files and snapshots
- Deleted SQL migration files for `0137_naive_power_pack`, `0138_common_mathemanic`, `0139_smiling_havok`, and `0140_great_lightspeed` as they are no longer needed.
- Removed corresponding snapshot files to maintain consistency in the database schema history.
2026-02-05 00:48:44 -06:00
Mauricio Siu
dc74d3057a fix(tests): update setup file path in Vitest configuration for clarity
- Changed the setup file path for global mocks in the Vitest configuration to a more explicit location, improving clarity and organization of test setup.
2026-02-04 23:36:00 -06:00
Mauricio Siu
ac833ef265 feat(tests): enhance Vitest configuration with additional environment variables and updated setup path
- Updated the setup file path for global mocks in the Vitest configuration to improve clarity.
- Added environment variables for GitHub and Google credentials to the test environment, facilitating integration testing.
2026-02-04 23:34:20 -06:00
Mauricio Siu
00e31f399e fix(db): update deprecation warning for legacy database credentials
- Added a condition to display the deprecation warning for legacy database credentials only in non-test environments.
- This change prevents unnecessary warnings during testing, improving the developer experience.
2026-02-04 23:33:17 -06:00
Mauricio Siu
8001e5d24a feat(tests): add values method to mock database for enhanced testing
- Introduced a mock implementation for the `values` method in the mock database setup.
- This addition improves the test environment by allowing more comprehensive simulation of database interactions.
2026-02-04 23:33:10 -06:00
Mauricio Siu
cfb9534e06 feat(tests): enhance mock database with web server settings for testing
- Added mock implementations for `webServerSettings` to support the `getWebServerSettings` function in tests.
- This update improves the test environment by simulating necessary database interactions for web server settings.
2026-02-04 23:28:51 -06:00
Mauricio Siu
8335f40238 feat(tests): add mock database setup for Vitest testing environment
- Introduced a new mock database setup file to simulate database interactions during tests.
- Updated Vitest configuration to include the mock setup file, enhancing test reliability and isolation.
2026-02-04 09:20:19 -06:00
Mauricio Siu
dc8148ae51 fix(db): update database URL configuration for production and development environments
- Modified the database URL assignment logic to differentiate between production and development environments.
- Ensured that the correct database URL is used based on the NODE_ENV variable, improving deployment flexibility.
2026-02-04 08:58:10 -06:00
Mauricio Siu
3307f62183 refactor(auth): remove unused SSO provider retrieval logic
- Deleted the import statement for `getSSOProviders` and the associated logic for fetching issuer origins from SSO providers.
- This cleanup improves code clarity by removing unnecessary dependencies and streamlining the trusted origins configuration.
2026-02-04 08:52:09 -06:00
Mauricio Siu
2b36381f8d fix: update import path for getPublicIpWithFallback in enterprise utility 2026-02-04 08:51:18 -06:00
Mauricio Siu
945406adc5 Merge branch 'canary' into feat/introduce-license-key-pay 2026-02-02 10:59:06 -06:00
Mauricio Siu
1e7522d173 Merge pull request #3580 from Bima42/fix/mistral-modal-require-bearer
fix: double bearer for mistral provider
2026-02-02 10:30:15 -06:00
Bima42
2b72b4888c fix: avoid enforce bearer for mistral in input 2026-02-02 11:45:45 +01:00
Mauricio Siu
8b1cc949c0 feat(sso): implement SSO provider retrieval functionality
- Added a new service to fetch SSO providers from the database, including relevant fields such as id, providerId, issuer, domain, oidcConfig, and samlConfig.
- This functionality will support future enhancements in SSO integration.
2026-02-01 22:44:08 -06:00
Mauricio Siu
a70018f70a feat(auth): add enterprise feature flags to user context and request validation
- Updated user context to include `enableEnterpriseFeatures` and `isValidEnterpriseLicense` properties.
- Modified request validation to set these properties based on user data, enhancing enterprise feature management.
- Adjusted the enterprise procedure to check user flags directly from the context instead of querying the database.
2026-02-01 22:01:13 -06:00
Mauricio Siu
71b87895eb refactor(auth): streamline trusted origins configuration and improve readability
- Changed the export of the `handler` and `api` constants to local scope for better clarity.
- Enhanced the trusted origins logic by restructuring the code for improved readability and maintainability.
- Commented out the cloud environment redirection logic for future consideration, aligning with previous changes in the codebase.
2026-02-01 21:38:07 -06:00
Mauricio Siu
354407cd12 chore(license): comment out cloud environment redirection logic in license settings page for future consideration 2026-02-01 20:11:19 -06:00
Mauricio Siu
766fd00be5 Merge branch 'canary' into feat/introduce-license-key-pay 2026-02-01 19:59:28 -06:00
Mauricio Siu
c31e970172 Merge pull request #3573 from bdkopen/update-next
chore: update Next to resolve 3 CVEs
2026-02-01 19:57:05 -06:00
Mauricio Siu
c56def9c97 fix(db): update database URL for Docker compatibility
- Commented out the old database URL for security reasons.
- Updated the database connection string to use the Docker service name for PostgreSQL, ensuring proper connectivity in containerized environments.
2026-02-01 19:54:43 -06:00
Mauricio Siu
aa558b3a8c feat(sso): update SAML registration dialog and settings for improved metadata handling
- Added support for IdP metadata XML in the SAML registration dialog, allowing users to paste full metadata for configuration.
- Updated the callback URL and audience handling to dynamically incorporate the base URL.
- Refactored the SSO settings to enable SAML provider registration and improved the display of callback URLs based on provider details.
- Enhanced trusted origins configuration in the authentication logic to include additional domains for development and production environments.
2026-02-01 19:50:33 -06:00
Mauricio Siu
11082f25d7 feat(sso): enhance OIDC registration mapping for Azure and other providers
- Updated the mapping logic in `register-oidc-dialog` to differentiate between Azure and other identity providers.
- Simplified the mapping structure for user attributes based on the issuer, improving flexibility in handling various OIDC providers.
2026-02-01 00:35:42 -06:00
Mauricio Siu
00ce8cad1b feat(license): enhance license key management and authorization checks
- Added authorization checks to ensure only users with the "owner" role can activate or deactivate license keys.
- Updated the menu item visibility logic to simplify role checks for admin and owner users.
- Commented out the cloud environment redirection logic in the license settings page for future consideration.
2026-01-31 18:03:03 -06:00
Mauricio Siu
dc756e2bbb refactor(auth): rename forgetPassword to requestPasswordReset for clarity
- Updated the method name from `forgetPassword` to `requestPasswordReset` in the password reset flow to better reflect its functionality.
2026-01-31 17:07:06 -06:00
Mauricio Siu
fb06cf8e55 feat(auth): add Okta domain to SSO provider list and adjust SSO request handling
- Included a new Okta domain in the array of allowed domains for SSO authentication.
- Modified the SSO request handling logic to return early if the request is an SSO callback, enhancing the flow of authentication.
2026-01-31 15:28:15 -06:00
Mauricio Siu
69ba901535 feat(sso): update SSO provider registration to handle multiple domains
- Refactored `register-oidc-dialog` and `register-saml-dialog` to accept an array of domains instead of a single domain string.
- Enhanced server-side validation to check for duplicate domains across registered providers.
- Updated SSO schema to reflect the change from a single domain to an array of domains, including validation for domain format.
2026-01-31 13:17:24 -06:00
bdkopen
4667cb525f chore: update next 2026-01-31 10:04:53 -05: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
139 changed files with 30790 additions and 2975 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

@@ -23,7 +23,7 @@
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.17.51",
"@types/node": "^20.16.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"tsx": "^4.16.2",

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

@@ -13,11 +13,11 @@ type MockCreateServiceOptions = {
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
vi.hoisted(() => {
const inspect = vi.fn<[], Promise<never>>();
const inspect = vi.fn<() => Promise<never>>();
const getService = vi.fn(() => ({ inspect }));
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
async () => undefined,
);
const createService = vi.fn<
(opts: MockCreateServiceOptions) => Promise<void>
>(async () => undefined);
const getRemoteDocker = vi.fn(async () => ({
getService,
createService,
@@ -80,7 +80,9 @@ describe("mechanizeDockerContainer", () => {
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
if (!call) {
throw new Error("createServiceMock should have been called once");
}
@@ -97,7 +99,9 @@ describe("mechanizeDockerContainer", () => {
await mechanizeDockerContainer(application);
expect(createServiceMock).toHaveBeenCalledTimes(1);
const call = createServiceMock.mock.calls[0];
const call = createServiceMock.mock.calls[0] as
| [MockCreateServiceOptions]
| undefined;
if (!call) {
throw new Error("createServiceMock should have been called once");
}

View File

@@ -0,0 +1,40 @@
import { vi } from "vitest";
/**
* Mock the DB module so tests that import from @dokploy/server (barrel)
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
* Without this, loading the server barrel pulls in lib/auth and db, which
* connect to localhost:5432 and cause ECONNREFUSED.
*/
vi.mock("@dokploy/server/db", () => {
const chain = () => chain;
chain.set = () => chain;
chain.where = () => chain;
chain.values = () => chain;
chain.returning = () => Promise.resolve([{}]);
chain.then = undefined;
const tableMock = {
findFirst: vi.fn(() => Promise.resolve(undefined)),
findMany: vi.fn(() => Promise.resolve([])),
insert: vi.fn(() => Promise.resolve([{}])),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
};
const createQueryMock = () => tableMock;
return {
db: {
select: vi.fn(() => chain),
insert: vi.fn(() => ({
values: () => ({ returning: () => Promise.resolve([{}]) }),
})),
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
query: new Proxy({} as Record<string, typeof tableMock>, {
get: () => tableMock,
}),
},
dbUrl: "postgres://mock:mock@localhost:5432/mock",
};
});

View File

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

View File

@@ -7,10 +7,15 @@ export default defineConfig({
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
pool: "forks",
setupFiles: [path.resolve(__dirname, "setup.ts")],
},
define: {
"process.env": {
NODE: "test",
GITHUB_CLIENT_ID: "test",
GITHUB_CLIENT_SECRET: "test",
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
},
},
plugins: [

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

@@ -18,9 +18,10 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
HeartIcon,
Key,
KeyRound,
Loader2,
LogIn,
type LucideIcon,
Package,
PieChart,
@@ -397,6 +398,24 @@ 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 }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
{
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"),
},
],
help: [
@@ -410,18 +429,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;

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,352 @@
"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 isAzure = data.issuer.includes("login.microsoftonline.com");
const mapping = isAzure
? {
id: "sub",
email: "preferred_username",
emailVerified: "email_verified",
name: "name",
}
: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "preferred_username",
image: "picture",
};
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domains: data.domains,
oidcConfig: {
clientId: data.clientId,
clientSecret: data.clientSecret,
scopes,
pkce: true,
mapping,
},
});
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,328 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, 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"),
idpMetadataXml: z.string().optional(),
});
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
children: React.ReactNode;
}
const formDefaultValues: SamlProviderForm = {
providerId: "",
issuer: "",
domains: [""],
entryPoint: "",
cert: "",
idpMetadataXml: "",
};
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const [baseURL, setBaseURL] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
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 {
// maybe add the /saml/metadata endpoint to the baseURL
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
const generateSpMetadata = (providerId: string) => {
return `<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>`;
};
await mutateAsync({
providerId: data.providerId,
issuer: data.issuer,
domains: data.domains,
samlConfig: {
entryPoint: data.entryPoint,
cert: data.cert,
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
audience: baseURL,
idpMetadata: data.idpMetadataXml?.trim()
? { metadata: data.idpMetadataXml.trim() }
: undefined,
spMetadata: {
metadata: generateSpMetadata(data.providerId),
},
mapping: {
id: "nameID",
email: "email",
name: "displayName",
firstName: "givenName",
lastName: "surname",
},
},
});
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="idpMetadataXml"
render={({ field }) => (
<FormItem>
<FormLabel>IdP metadata XML (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
rows={5}
className="font-mono text-xs"
{...field}
/>
</FormControl>
<FormDescription>
Some IdPs require full metadata; paste the XML here to
override issuer/entry point/cert.
</FormDescription>
<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,371 @@
"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}"}
{detailsProvider.samlConfig
? "/api/auth/sso/saml2/callback/"
: "/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

@@ -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,18 @@
CREATE TABLE "sso_provider" (
"id" text PRIMARY KEY NOT NULL,
"issuer" text NOT NULL,
"oidc_config" text,
"saml_config" text,
"provider_id" text NOT NULL,
"user_id" text,
"organization_id" text,
"domain" text NOT NULL,
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "licenseKey" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "trustedOrigins" text[];--> 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;--> statement-breakpoint
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

View File

@@ -946,6 +946,27 @@
"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": 1770274109332,
"tag": "0137_colossal_sally_floyd",
"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",
@@ -106,7 +107,7 @@
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-orm": "^0.39.3",
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.8",
@@ -116,7 +117,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.0.10",
"next": "^16.1.6",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
@@ -164,7 +165,7 @@
"@types/js-cookie": "^3.0.6",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",
"@types/node": "^20.16.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
@@ -174,7 +175,7 @@
"@types/swagger-ui-react": "^4.19.0",
"@types/ws": "8.5.10",
"autoprefixer": "10.4.12",
"drizzle-kit": "^0.30.6",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"lint-staged": "^15.5.2",
"memfs": "^4.17.2",
@@ -182,7 +183,7 @@
"tsx": "^4.16.2",
"typescript": "^5.8.3",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^1.6.1"
"vitest": "^4.0.18"
},
"ct3aMetadata": {
"initVersion": "7.25.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,76 @@
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);
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

@@ -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,7 @@ interface Props {
}
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -62,8 +67,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,45 +164,54 @@ 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 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">
@@ -221,97 +233,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

@@ -63,7 +63,7 @@ export default function Home() {
const onSubmit = async (values: Login) => {
setIsLoading(true);
const { error } = await authClient.forgetPassword({
const { error } = await authClient.requestPasswordReset({
email: values.email,
redirectTo: "/reset-password",
});

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,221 @@
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 (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to activate a license key",
});
}
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",
});
}
if (ctx.user.role !== "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not authorized to deactivate a license key",
});
}
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,180 @@
import { normalizeTrustedOrigin } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { member, ssoProvider, user } from "@dokploy/server/db/schema";
import { ssoProviderBodySchema } from "@dokploy/server/db/schema/sso";
import { requestToHeaders } from "@dokploy/server/index";
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";
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 }) => {
// Obtener el provider antes de eliminarlo para obtener sus dominios
const providerToDelete = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
domain: true,
issuer: true,
},
});
if (!providerToDelete) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to delete it",
});
}
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",
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const organizationId = ctx.session.activeOrganizationId;
const providers = await db.query.ssoProvider.findMany({
columns: {
domain: true,
},
});
for (const provider of providers) {
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
body: {
...input,
organizationId,
domain,
},
headers: requestToHeaders(ctx.req),
});
return { success: 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

@@ -31,7 +31,14 @@ import { db } from "@/server/db";
*/
interface CreateContextOptions {
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
user:
| (User & {
role: "member" | "admin" | "owner";
ownerId: string;
enableEnterpriseFeatures: boolean;
isValidEnterpriseLicense: boolean;
})
| null;
session:
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
| null;
@@ -217,3 +224,35 @@ 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" });
}
if (
!ctx.user?.enableEnterpriseFeatures ||
!ctx.user.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,81 @@
import { getPublicIpWithFallback, LICENSE_KEY_URL } from "@dokploy/server";
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

@@ -13,7 +13,7 @@
"@hono/zod-validator": "0.3.0",
"bullmq": "5.4.2",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.39.3",
"drizzle-orm": "^0.41.0",
"hono": "^4.7.10",
"ioredis": "5.4.1",
"pino": "9.4.0",
@@ -23,7 +23,7 @@
"zod": "^3.25.32"
},
"devDependencies": {
"@types/node": "^20.17.51",
"@types/node": "^20.16.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"tsx": "^4.16.2",

View File

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

View File

@@ -1,45 +0,0 @@
# EXAMPLE USAGE:
#
# Refer for explanation to following link:
# https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
#
# pre-push:
# commands:
# packages-audit:
# tags: frontend security
# run: yarn audit
# gems-audit:
# tags: backend security
# run: bundle audit
#
# pre-commit:
# parallel: true
# commands:
# eslint:
# glob: "*.{js,ts,jsx,tsx}"
# run: yarn eslint {staged_files}
# rubocop:
# tags: backend style
# glob: "*.rb"
# exclude: '(^|/)(application|routes)\.rb$'
# run: bundle exec rubocop --force-exclusion {all_files}
# govet:
# tags: backend style
# files: git ls-files -m
# glob: "*.go"
# run: go vet {files}
# scripts:
# "hello.js":
# runner: node
# "any.go":
# runner: go run
commit-msg:
commands:
commitlint:
# run: "npx commitlint --edit $1"
pre-commit:
commands:
check:
# run: "pnpm check"

View File

@@ -24,12 +24,9 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.1",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@types/node": "^18.19.104",
"@types/node": "^20.16.0",
"dotenv": "16.4.5",
"esbuild": "0.20.2",
"lefthook": "1.8.4",
"lint-staged": "^15.5.2",
"tsx": "4.16.2"
},
@@ -43,11 +40,6 @@
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
]
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"resolutions": {
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"

View File

@@ -0,0 +1,274 @@
// import { relations } from "drizzle-orm";
// import {
// pgTable,
// text,
// timestamp,
// boolean,
// integer,
// index,
// uniqueIndex,
// } from "drizzle-orm/pg-core";
// export const user = pgTable("user", {
// id: text("id").primaryKey(),
// firstName: text("first_name").notNull(),
// email: text("email").notNull().unique(),
// emailVerified: boolean("email_verified").default(false).notNull(),
// image: text("image"),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .defaultNow()
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// twoFactorEnabled: boolean("two_factor_enabled").default(false),
// role: text("role"),
// ownerId: text("owner_id"),
// allowImpersonation: boolean("allow_impersonation").default(false),
// lastName: text("last_name").default(""),
// });
// export const session = pgTable(
// "session",
// {
// id: text("id").primaryKey(),
// expiresAt: timestamp("expires_at").notNull(),
// token: text("token").notNull().unique(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// ipAddress: text("ip_address"),
// userAgent: text("user_agent"),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// activeOrganizationId: text("active_organization_id"),
// },
// (table) => [index("session_userId_idx").on(table.userId)],
// );
// export const account = pgTable(
// "account",
// {
// id: text("id").primaryKey(),
// accountId: text("account_id").notNull(),
// providerId: text("provider_id").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// accessToken: text("access_token"),
// refreshToken: text("refresh_token"),
// idToken: text("id_token"),
// accessTokenExpiresAt: timestamp("access_token_expires_at"),
// refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
// scope: text("scope"),
// password: text("password"),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// },
// (table) => [index("account_userId_idx").on(table.userId)],
// );
// export const verification = pgTable(
// "verification",
// {
// id: text("id").primaryKey(),
// identifier: text("identifier").notNull(),
// value: text("value").notNull(),
// expiresAt: timestamp("expires_at").notNull(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .defaultNow()
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// },
// (table) => [index("verification_identifier_idx").on(table.identifier)],
// );
// export const apikey = pgTable(
// "apikey",
// {
// id: text("id").primaryKey(),
// name: text("name"),
// start: text("start"),
// prefix: text("prefix"),
// key: text("key").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// refillInterval: integer("refill_interval"),
// refillAmount: integer("refill_amount"),
// lastRefillAt: timestamp("last_refill_at"),
// enabled: boolean("enabled").default(true),
// rateLimitEnabled: boolean("rate_limit_enabled").default(true),
// rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
// rateLimitMax: integer("rate_limit_max").default(10),
// requestCount: integer("request_count").default(0),
// remaining: integer("remaining"),
// lastRequest: timestamp("last_request"),
// expiresAt: timestamp("expires_at"),
// createdAt: timestamp("created_at").notNull(),
// updatedAt: timestamp("updated_at").notNull(),
// permissions: text("permissions"),
// metadata: text("metadata"),
// },
// (table) => [
// index("apikey_key_idx").on(table.key),
// index("apikey_userId_idx").on(table.userId),
// ],
// );
// export const ssoProvider = pgTable("sso_provider", {
// id: text("id").primaryKey(),
// issuer: text("issuer").notNull(),
// oidcConfig: text("oidc_config"),
// samlConfig: text("saml_config"),
// userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
// providerId: text("provider_id").notNull().unique(),
// organizationId: text("organization_id"),
// domain: text("domain").notNull(),
// });
// export const twoFactor = pgTable(
// "two_factor",
// {
// id: text("id").primaryKey(),
// secret: text("secret").notNull(),
// backupCodes: text("backup_codes").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// },
// (table) => [
// index("twoFactor_secret_idx").on(table.secret),
// index("twoFactor_userId_idx").on(table.userId),
// ],
// );
// export const organization = pgTable(
// "organization",
// {
// id: text("id").primaryKey(),
// name: text("name").notNull(),
// slug: text("slug").notNull().unique(),
// logo: text("logo"),
// createdAt: timestamp("created_at").notNull(),
// metadata: text("metadata"),
// },
// (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
// );
// export const member = pgTable(
// "member",
// {
// id: text("id").primaryKey(),
// organizationId: text("organization_id")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// role: text("role").default("member").notNull(),
// createdAt: timestamp("created_at").notNull(),
// },
// (table) => [
// index("member_organizationId_idx").on(table.organizationId),
// index("member_userId_idx").on(table.userId),
// ],
// );
// export const invitation = pgTable(
// "invitation",
// {
// id: text("id").primaryKey(),
// organizationId: text("organization_id")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// email: text("email").notNull(),
// role: text("role"),
// status: text("status").default("pending").notNull(),
// expiresAt: timestamp("expires_at").notNull(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// inviterId: text("inviter_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// },
// (table) => [
// index("invitation_organizationId_idx").on(table.organizationId),
// index("invitation_email_idx").on(table.email),
// ],
// );
// export const userRelations = relations(user, ({ many }) => ({
// sessions: many(session),
// accounts: many(account),
// apikeys: many(apikey),
// ssoProviders: many(ssoProvider),
// twoFactors: many(twoFactor),
// members: many(member),
// invitations: many(invitation),
// }));
// export const sessionRelations = relations(session, ({ one }) => ({
// user: one(user, {
// fields: [session.userId],
// references: [user.id],
// }),
// }));
// export const accountRelations = relations(account, ({ one }) => ({
// user: one(user, {
// fields: [account.userId],
// references: [user.id],
// }),
// }));
// export const apikeyRelations = relations(apikey, ({ one }) => ({
// user: one(user, {
// fields: [apikey.userId],
// references: [user.id],
// }),
// }));
// export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
// user: one(user, {
// fields: [ssoProvider.userId],
// references: [user.id],
// }),
// }));
// export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
// user: one(user, {
// fields: [twoFactor.userId],
// references: [user.id],
// }),
// }));
// export const organizationRelations = relations(organization, ({ many }) => ({
// members: many(member),
// invitations: many(invitation),
// }));
// export const memberRelations = relations(member, ({ one }) => ({
// organization: one(organization, {
// fields: [member.organizationId],
// references: [organization.id],
// }),
// user: one(user, {
// fields: [member.userId],
// references: [user.id],
// }),
// }));
// export const invitationRelations = relations(invitation, ({ one }) => ({
// organization: one(organization, {
// fields: [invitation.organizationId],
// references: [organization.id],
// }),
// user: one(user, {
// fields: [invitation.inviterId],
// references: [user.id],
// }),
// }));

View File

@@ -26,7 +26,8 @@
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
"typecheck": "tsc --noEmit",
"dbml:generate": "npx tsx src/db/schema/dbml.ts"
"dbml:generate": "npx tsx src/db/schema/dbml.ts",
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
@@ -36,26 +37,27 @@
"@ai-sdk/mistral": "^2.0.7",
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@better-auth/utils": "0.2.4",
"@better-auth/utils": "0.3.0",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^0.0.21",
"@better-auth/sso":"1.4.18",
"@trpc/server": "^10.45.2",
"adm-zip": "^0.5.16",
"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",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "^0.39.3",
"drizzle-orm": "^0.41.0",
"drizzle-zod": "0.5.1",
"yaml": "2.8.1",
"lodash": "4.17.21",
@@ -82,13 +84,14 @@
"semver": "7.7.3"
},
"devDependencies": {
"@better-auth/cli": "1.4.18",
"@types/semver": "7.7.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",
"@types/node": "^20.16.0",
"@types/node-schedule": "2.1.6",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
@@ -97,7 +100,7 @@
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
"drizzle-kit": "^0.30.6",
"drizzle-kit": "^0.31.4",
"esbuild": "0.20.2",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.5.3",

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