Compare commits

...

72 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
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
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
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
45 changed files with 12732 additions and 1106 deletions

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

@@ -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

@@ -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

@@ -18,8 +18,10 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
Key,
KeyRound,
Loader2,
LogIn,
type LucideIcon,
Package,
PieChart,
@@ -396,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: [

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

@@ -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

View File

@@ -960,6 +960,13 @@
"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

@@ -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"

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -24,7 +24,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.1.1",
"@types/node": "^18.19.104",
"@types/node": "^20.16.0",
"dotenv": "16.4.5",
"esbuild": "0.20.2",
"lint-staged": "^15.5.2",

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",

View File

@@ -26,7 +26,8 @@ if (DATABASE_URL) {
password,
)}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
} else {
console.warn(`
if (process.env.NODE_ENV !== "test") {
console.warn(`
⚠️ [DEPRECATED DATABASE CONFIG]
You are using the legacy hardcoded database credentials.
This mode WILL BE REMOVED in a future release.
@@ -34,6 +35,13 @@ if (DATABASE_URL) {
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
`);
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
}
if (process.env.NODE_ENV === "production") {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
} else {
dbUrl =
"postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy";
}
}

View File

@@ -9,6 +9,7 @@ import {
import { nanoid } from "nanoid";
import { projects } from "./project";
import { server } from "./server";
import { ssoProvider } from "./sso";
import { user } from "./user";
export const account = pgTable("account", {
@@ -78,6 +79,7 @@ export const organizationRelations = relations(
servers: many(server),
projects: many(projects),
members: many(member),
ssoProviders: many(ssoProvider),
}),
);

View File

@@ -32,6 +32,7 @@ export * from "./server";
export * from "./session";
export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./user";
export * from "./utils";
export * from "./volume-backups";

View File

@@ -0,0 +1,132 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
export const ssoProvider = pgTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
providerId: text("provider_id").notNull().unique(),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
organization: one(organization, {
fields: [ssoProvider.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
export const ssoProviderBodySchema = z.object({
providerId: z.string({}),
issuer: z.string({}),
domains: z
.string()
.array()
.transform((val) =>
Array.from(
new Set(val.map((d) => d.trim().toLowerCase()).filter(Boolean)),
),
)
.refine((val) => val.every((d) => domainRegex.test(d)), {
message: "Invalid domain",
path: ["domains"],
}),
oidcConfig: z
.object({
clientId: z.string({}),
clientSecret: z.string({}),
authorizationEndpoint: z.string({}).optional(),
tokenEndpoint: z.string({}).optional(),
userInfoEndpoint: z.string({}).optional(),
tokenEndpointAuthentication: z
.enum(["client_secret_post", "client_secret_basic"])
.optional(),
jwksEndpoint: z.string({}).optional(),
discoveryEndpoint: z.string().optional(),
skipDiscovery: z.boolean().optional(),
scopes: z.array(z.string()).optional(),
pkce: z.boolean().default(true).optional(),
mapping: z
.object({
id: z.string({}),
email: z.string({}),
emailVerified: z.string({}).optional(),
name: z.string({}),
image: z.string({}).optional(),
extraFields: z.record(z.string(), z.any()).optional(),
})
.optional(),
})
.optional(),
samlConfig: z
.object({
entryPoint: z.string({}),
cert: z.string({}),
callbackUrl: z.string({}),
audience: z.string().optional(),
idpMetadata: z
.object({
metadata: z.string().optional(),
entityID: z.string().optional(),
cert: z.string().optional(),
privateKey: z.string().optional(),
privateKeyPass: z.string().optional(),
isAssertionEncrypted: z.boolean().optional(),
encPrivateKey: z.string().optional(),
encPrivateKeyPass: z.string().optional(),
singleSignOnService: z
.array(
z.object({
Binding: z.string(),
Location: z.string(),
}),
)
.optional(),
})
.optional(),
spMetadata: z.object({
metadata: z.string().optional(),
entityID: z.string().optional(),
binding: z.string().optional(),
privateKey: z.string().optional(),
privateKeyPass: z.string().optional(),
isAssertionEncrypted: z.boolean().optional(),
encPrivateKey: z.string().optional(),
encPrivateKeyPass: z.string().optional(),
}),
wantAssertionsSigned: z.boolean().optional(),
authnRequestsSigned: z.boolean().optional(),
signatureAlgorithm: z.string().optional(),
digestAlgorithm: z.string().optional(),
identifierFormat: z.string().optional(),
privateKey: z.string().optional(),
decryptionPvk: z.string().optional(),
additionalParams: z.record(z.string(), z.any()).optional(),
mapping: z
.object({
id: z.string({}),
email: z.string({}),
emailVerified: z.string({}).optional(),
name: z.string({}),
firstName: z.string({}).optional(),
lastName: z.string({}).optional(),
extraFields: z.record(z.string(), z.any()).optional(),
})
.optional(),
})
.optional(),
organizationId: z.string({}).optional(),
overrideUserInfo: z.boolean({}).default(false).optional(),
});

View File

@@ -14,6 +14,7 @@ import { account, apikey, organization } from "./account";
import { backups } from "./backups";
import { projects } from "./project";
import { schedules } from "./schedule";
import { ssoProvider } from "./sso";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@@ -53,9 +54,18 @@ export const user = pgTable("user", {
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
// Enterprise / proprietary features
enableEnterpriseFeatures: boolean("enableEnterpriseFeatures")
.notNull()
.default(false),
licenseKey: text("licenseKey"),
isValidEnterpriseLicense: boolean("isValidEnterpriseLicense")
.notNull()
.default(false),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
trustedOrigins: text("trustedOrigins").array(),
});
export const usersRelations = relations(user, ({ one, many }) => ({
@@ -66,6 +76,7 @@ export const usersRelations = relations(user, ({ one, many }) => ({
organizations: many(organization),
projects: many(projects),
apiKeys: many(apikey),
ssoProviders: many(ssoProvider),
backups: many(backups),
schedules: many(schedules),
}));
@@ -75,6 +86,8 @@ const createSchema = createInsertSchema(user, {
isRegistered: z.boolean().optional(),
}).omit({
role: true,
trustedOrigins: true,
isValidEnterpriseLicense: true,
});
export const apiCreateUserInvitation = createSchema.pick({}).extend({

View File

@@ -31,6 +31,7 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
export * from "./services/proprietary/sso";
export * from "./services/redirect";
export * from "./services/redis";
export * from "./services/registry";
@@ -79,6 +80,7 @@ export * from "./utils/builders/paketo";
export * from "./utils/builders/static";
export * from "./utils/builders/utils";
export * from "./utils/cluster/upload";
export * from "./utils/crons/enterprise";
export * from "./utils/databases/rebuild";
export * from "./utils/docker/collision";
export * from "./utils/docker/compose";

View File

@@ -1,4 +1,5 @@
import type { IncomingMessage } from "node:http";
import { sso } from "@better-auth/sso";
import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
@@ -8,7 +9,7 @@ import { and, desc, eq } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
import { db } from "../db";
import * as schema from "../db/schema";
import { getUserByToken } from "../services/admin";
import { getTrustedOrigins, getUserByToken } from "../services/admin";
import {
getWebServerSettings,
updateWebServerSettings,
@@ -22,6 +23,12 @@ const { handler, api } = betterAuth({
provider: "pg",
schema: schema,
}),
disabledPaths: [
"/sso/register",
"/organization/create",
"/organization/update",
"/organization/delete",
],
appName: "Dokploy",
socialProviders: {
github: {
@@ -36,24 +43,24 @@ const { handler, api } = betterAuth({
logger: {
disabled: process.env.NODE_ENV === "production",
},
...(!IS_CLOUD && {
async trustedOrigins() {
const settings = await getWebServerSettings();
if (!settings) {
return [];
}
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...(process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
]
: []),
];
},
}),
async trustedOrigins() {
const trustedOrigins = await getTrustedOrigins();
if (IS_CLOUD) {
return trustedOrigins;
}
const settings = await getWebServerSettings();
if (!settings) {
return [];
}
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...(process.env.NODE_ENV === "development"
? ["http://localhost:3000"]
: []),
...trustedOrigins,
];
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
@@ -106,6 +113,10 @@ const { handler, api } = betterAuth({
});
}
} else {
const isSSORequest = context?.path.includes("/sso");
if (isSSORequest) {
return;
}
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
@@ -118,6 +129,7 @@ const { handler, api } = betterAuth({
}
},
after: async (user, context) => {
const isSSORequest = context?.path.includes("/sso");
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
@@ -173,6 +185,29 @@ const { handler, api } = betterAuth({
isDefault: true, // Mark first organization as default
});
});
} else if (isSSORequest) {
const providerId = context?.params?.providerId;
if (!providerId) {
throw new APIError("BAD_REQUEST", {
message: "Provider ID is required",
});
}
const provider = await db.query.ssoProvider.findFirst({
where: eq(schema.ssoProvider.providerId, providerId),
});
if (!provider) {
throw new APIError("BAD_REQUEST", {
message: "Provider not found",
});
}
await db.insert(schema.member).values({
userId: user.id,
organizationId: provider?.organizationId || "",
role: "member",
createdAt: new Date(),
isDefault: true,
});
}
},
},
@@ -234,12 +269,23 @@ const { handler, api } = betterAuth({
input: true,
defaultValue: "",
},
enableEnterpriseFeatures: {
type: "boolean",
required: false,
input: false,
},
isValidEnterpriseLicense: {
type: "boolean",
required: false,
input: false,
},
},
},
plugins: [
apiKey({
enableMetadata: true,
}),
sso(),
twoFactor(),
organization({
async sendInvitationEmail(data, _request) {
@@ -273,6 +319,7 @@ const { handler, api } = betterAuth({
export const auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
};
export const validateRequest = async (request: IncomingMessage) => {
@@ -352,6 +399,8 @@ export const validateRequest = async (request: IncomingMessage) => {
twoFactorEnabled: userFromDb.twoFactorEnabled,
role: member?.role || "member",
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
enableEnterpriseFeatures: userFromDb.enableEnterpriseFeatures,
isValidEnterpriseLicense: userFromDb.isValidEnterpriseLicense,
},
};
@@ -390,10 +439,15 @@ export const validateRequest = async (request: IncomingMessage) => {
),
with: {
organization: true,
user: true,
},
});
session.user.role = member?.role || "member";
session.user.enableEnterpriseFeatures =
member?.user.enableEnterpriseFeatures || false;
session.user.isValidEnterpriseLicense =
member?.user.isValidEnterpriseLicense || false;
if (member) {
session.user.ownerId = member.organization.ownerId;
} else {

View File

@@ -116,3 +116,22 @@ export const getDokployUrl = async () => {
}
return `http://${settings?.serverIp}:${process.env.PORT}`;
};
export const getTrustedOrigins = async () => {
const members = await db.query.member.findMany({
where: eq(member.role, "owner"),
with: {
user: true,
},
});
if (members.length === 0) {
return [];
}
const trustedOrigins = members.flatMap(
(member) => member.user.trustedOrigins || [],
);
return Array.from(new Set(trustedOrigins));
};

View File

@@ -0,0 +1,35 @@
import { db } from "@dokploy/server/db";
export const getSSOProviders = async () => {
const providers = await db.query.ssoProvider.findMany({
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
},
});
return providers;
};
export const requestToHeaders = (req: {
headers?: Record<string, string | string[] | undefined>;
}): Headers => {
const headers = new Headers();
if (req?.headers) {
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined && key.toLowerCase() !== "host") {
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
}
}
return headers;
};
export const normalizeTrustedOrigin = (value: string): string => {
// Keep it simple: trim and remove trailing slashes.
// e.g. "https://example.com/" -> "https://example.com"
return value.trim().replace(/\/+$/, "");
};

View File

@@ -103,7 +103,7 @@ export const getProviderHeaders = (
// Mistral
if (apiUrl.includes("mistral")) {
return {
Authorization: apiKey,
Authorization: `Bearer ${apiKey}`,
};
}

View File

@@ -0,0 +1,68 @@
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
import { and, eq, isNotNull } from "drizzle-orm";
import { scheduleJob } from "node-schedule";
import { db } from "../../db/index";
import { user as userSchema } from "../../db/schema/user";
export const LICENSE_KEY_URL =
process.env.NODE_ENV === "development"
? "http://localhost:4002"
: "https://licenses.dokploy.com";
export const initEnterpriseBackupCronJobs = async () => {
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
const users = await db.query.user.findMany({
where: and(
isNotNull(userSchema.licenseKey),
isNotNull(userSchema.enableEnterpriseFeatures),
eq(userSchema.isValidEnterpriseLicense, true),
),
});
for (const user of users) {
if (user.isValidEnterpriseLicense) {
console.log(
"Validating license key....",
user.firstName,
user.lastName,
);
try {
const isValid = await validateLicenseKey(user.licenseKey || "");
if (!isValid) {
throw new Error("License key is invalid");
}
} catch (error) {
await db
.update(userSchema)
.set({ isValidEnterpriseLicense: false })
.where(eq(userSchema.id, user.id));
}
}
}
});
};
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;
}
};

3343
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff