Compare commits

...

114 Commits

Author SHA1 Message Date
Mauricio Siu
9067452a38 feat: add role presets for custom role management
- Introduced predefined role presets with default permissions for viewer, developer, deployer, and devops roles to streamline custom role creation.
- Enhanced the UI to allow users to start from a preset role, improving the user experience in managing custom roles.
- Updated imports and adjusted component formatting for better readability.
2026-03-17 23:33:45 -06:00
Mauricio Siu
1fa4d5b2ba refactor: improve formatting and readability in billing and users components
- Enhanced code readability by adjusting formatting in the ShowBilling component, ensuring consistent line breaks and indentation.
- Updated the ShowUsers component to improve the layout of the warning message for users with custom roles without a valid license, maintaining clarity in the alert presentation.
2026-03-17 23:17:30 -06:00
Mauricio Siu
bade36ea9d feat: add alert for users with custom roles without a valid license
- Introduced an AlertBlock to notify users with custom roles that a valid Enterprise license is required for those roles to function.
- Implemented logic to check for users assigned to custom roles and display a warning if the license is invalid.
2026-03-17 23:16:17 -06:00
Mauricio Siu
0c22041623 refactor: update billing component to manage server quantities for hobby and startup tiers
- Replaced single server quantity state with separate states for hobby and startup server quantities.
- Adjusted calculations and UI elements to reflect the new state management for each tier.
- Ensured proper handling of server quantity in pricing calculations and button states.
2026-03-17 23:11:50 -06:00
Mauricio Siu
cccee05173 Merge pull request #4023 from Dokploy/4021-discord-error-notifications-fail-due-to-content-exceeding-max-embed-length
fix: truncate error message in backup notifications to 1010 characters
2026-03-17 22:47:35 -06:00
Mauricio Siu
9f9c8fccf2 Update packages/server/src/utils/notifications/database-backup.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-17 22:47:26 -06:00
Mauricio Siu
ad2e53a67a fix: truncate error message in backup notifications to 1010 characters
- Updated the error message formatting in both database and volume backup notification functions to limit the displayed message length, ensuring better readability and preventing overflow.
2026-03-17 22:17:36 -06:00
Mauricio Siu
00f3853bd7 chore: remove settings.json file for command permissions in Claude
- Deleted the settings.json file that defined permissions for various Bash commands and the default mode for Claude.
2026-03-17 18:19:37 -06:00
Mauricio Siu
2880327e94 feat: add settings configuration for command permissions in Claude
- Introduced a new settings.json file to define permissions for various Bash commands and set the default mode to bypassPermissions.
- Updated the version in package.json to v0.28.7.
2026-03-17 18:18:04 -06:00
Mauricio Siu
827b84f57e Merge pull request #4001 from WalidDevIO/fix/volume-backup-turn-off
fix(volume-backups): restart container before S3 upload in volume backup
2026-03-17 08:53:02 -06:00
Mauricio Siu
11aa8fe0c5 Update packages/server/src/utils/volume-backups/backup.ts 2026-03-17 08:51:31 -06:00
autofix-ci[bot]
b9ac720d99 [autofix.ci] apply automated fixes 2026-03-17 06:25:03 +00:00
Mauricio Siu
77b0ff7bbf Merge pull request #4016 from Dokploy/4003-first-application-deploy-to-swarm-worker-fails-with-unauthorized-no-such-image-retry-succeeds
fix: handle optional authConfig in mechanizeDockerContainer function
2026-03-17 00:18:14 -06:00
Mauricio Siu
e7af2c0ebd fix: handle optional authConfig in mechanizeDockerContainer function
- Updated the mechanizeDockerContainer function to conditionally use authConfig when creating a Docker service, ensuring proper service creation based on authentication settings.
2026-03-17 00:17:51 -06:00
Mauricio Siu
6a1bedb90f Merge pull request #4015 from Dokploy/3971-abnormal-webserver-backup-file-size-increase-500-kb-4-gb-overnight
fix: exclude volume-backups from web server backup rsync command
2026-03-16 23:27:16 -06:00
Mauricio Siu
a2f142174b fix: exclude volume-backups from web server backup rsync command
- Updated the rsync command in the runWebServerBackup function to exclude the 'volume-backups/' directory, ensuring that unnecessary data is not copied during the backup process.
2026-03-16 23:26:33 -06:00
Mauricio Siu
f4ce304a04 Merge pull request #4013 from Dokploy/3983-custom-database-docker-image-reset-to-default-for-any-unrelated-change
feat: add optional dockerImage field to database schemas
2026-03-16 16:20:18 -06:00
Mauricio Siu
bb521f3e7e feat: add optional dockerImage field to database schemas
- Updated MariaDB, MongoDB, MySQL, PostgreSQL, and Redis schemas to include an optional dockerImage field for enhanced configuration flexibility.
2026-03-16 16:19:37 -06:00
Mauricio Siu
baaa470234 Merge pull request #4012 from Dokploy/3979-collapsed-sidebar-state-has-usability-and-visual-issues
3979 collapsed sidebar state has usability and visual issues
2026-03-16 15:34:05 -06:00
autofix-ci[bot]
4871520dbb [autofix.ci] apply automated fixes 2026-03-16 21:33:40 +00:00
Mauricio Siu
dad49ec96f refactor: move TimeBadge to BreadcrumbSidebar for conditional rendering
- Removed TimeBadge from the ShowProjects component and integrated it into the BreadcrumbSidebar.
- Added a query to determine if the environment is cloud-based, allowing for conditional display of the TimeBadge.
- Updated layout in BreadcrumbSidebar for improved spacing and organization.
2026-03-16 15:32:59 -06:00
Mauricio Siu
ce4e37c75b refactor: simplify sidebar state handling
- Replaced direct state checks with a derived variable `isCollapsed` for better readability and maintainability.
- Updated class names and conditions in the SidebarLogo component to use the new `isCollapsed` variable.
- Adjusted overflow behavior in Sidebar and SidebarContent components for improved layout management.
2026-03-16 15:29:25 -06:00
Mauricio Siu
c317ec39cb Merge pull request #3977 from azizbecha/fix/watch-path-tooltip-submit
fix: prevent Watch Paths tooltip button from submitting the form
2026-03-16 14:55:35 -06:00
Mauricio Siu
a4e9c6e890 feat: implement audit logs and custom role management components
- Added new components for displaying and managing audit logs, including a data table and filters for user actions.
- Introduced a custom roles management interface, allowing users to create and modify roles with specific permissions.
- Updated permission checks to ensure proper access control for audit logs and custom roles.
- Refactored existing components to integrate new functionality and improve user experience.
2026-03-16 11:13:24 -06:00
Mauricio Siu
72fb85f616 Merge pull request #4009 from Dokploy/feat/add-custom-roles
feat: add comprehensive permission tests and enhance permission check…
2026-03-16 01:12:30 -06:00
Mauricio Siu
1e7a6f2071 refactor: update custom role handling in API
- Replaced the delete operation with an update for organization roles, ensuring existing roles are modified instead of removed.
- Adjusted the return value to reflect the updated role instead of a newly created entry.
- Reintroduced the audit logging functionality for role updates.
2026-03-15 23:33:20 -06:00
autofix-ci[bot]
5ffd664570 [autofix.ci] apply automated fixes 2026-03-16 05:02:48 +00:00
Mauricio Siu
947100c041 refactor: replace existing organization_role and audit_log tables with new definitions
- Deleted the old SQL files for organization_role and audit_log.
- Introduced new SQL file defining organization_role and audit_log with updated foreign key constraints and indexes.
- Updated metadata snapshots to reflect the new table structures and relationships.
- Adjusted access control permissions for backup and notification operations to include update capabilities.
2026-03-15 23:02:23 -06:00
autofix-ci[bot]
5410a56638 [autofix.ci] apply automated fixes 2026-03-15 22:43:40 +00:00
Mauricio Siu
8127dc4536 feat: add comprehensive permission tests and enhance permission checks in components
- Introduced new test files for permission checks, including `check-permission.test.ts`, `enterprise-only-resources.test.ts`, `resolve-permissions.test.ts`, and `service-access.test.ts`.
- Implemented permission checks in various components to ensure actions are gated by user permissions, including `ShowTraefikConfig`, `UpdateTraefikConfig`, `ShowVolumes`, `ShowDomains`, and others.
- Enhanced the logic for displaying UI elements based on user permissions, ensuring that only authorized users can access or modify resources.
2026-03-15 16:42:48 -06:00
EL OUAZIZI Walid
2f37235aea fix(volume-backups): restart container before S3 upload in volume backup 2026-03-15 06:46:33 +01:00
Aziz Becha
290267bca4 fix: prevent Watch Paths tooltip button from submitting the form 2026-03-12 01:18:00 +01:00
Mauricio Siu
8eace173b9 Merge pull request #3969 from Dokploy/refactor/upgrade-better-auth
chore: update better-auth dependencies to version 1.5.4 and refactor …
2026-03-10 16:30:23 -06:00
Mauricio Siu
c9a9ed8164 Merge pull request #3967 from desen94/fix/invalidate-notification-cache-on-edit
fix: invalidate notification.one query cache on update
2026-03-10 16:29:03 -06:00
Mauricio Siu
30428053e8 chore: update better-auth dependencies to version 1.5.4 and refactor imports in auth-client and auth modules 2026-03-10 16:25:45 -06:00
Волков Дмитрий Сергеевич
1c0dbbcfd6 fix: invalidate notification.one query cache on update
When editing a notification, only the notification.all query cache was
invalidated. The notification.one query retained stale data, causing
the edit form to display previous values on subsequent edits.
2026-03-10 23:16:54 +05:00
Mauricio Siu
178f4fbdf7 fix: update Docker API version constant to use DOKPLOY environment variable 2026-03-10 10:12:00 -06:00
Mauricio Siu
2c07a4b2e3 Bump version from v0.28.5 to v0.28.6 2026-03-10 10:02:53 -06:00
Mauricio Siu
75a797097b Merge pull request #3952 from jirkavrba/copy-webhook-url
feat(deployments): Add option to copy webhook url by clicking on it
2026-03-10 10:00:04 -06:00
Mauricio Siu
2879816e41 Merge pull request #3962 from Dokploy/3955-bug-typeerror-invalid-url-with-dockersock-preventing-any-deployments-when-building-dockerfiles-on-version-v0285
feat: update Docker configuration to use DOKPLOY environment variables
2026-03-10 02:12:02 -06:00
Mauricio Siu
3501996b9e feat: update Docker configuration to use DOKPLOY environment variables 2026-03-10 02:11:36 -06:00
Mauricio Siu
47556a6486 Merge pull request #3960 from Dokploy/3956-preview-deployments-with-previewlabels-fail-due-to-webhook-race-condition
feat: add support for 'labeled' action in GitHub deployment handler
2026-03-10 02:09:40 -06:00
Mauricio Siu
e554adc376 feat: add support for 'labeled' action in GitHub deployment handler 2026-03-10 02:09:16 -06:00
Mauricio Siu
1804b935f6 Merge pull request #3959 from Dokploy/feat/add-new-whitelabeling
Feat/add new whitelabeling
2026-03-10 02:07:19 -06:00
Mauricio Siu
985c9102da refactor: remove primaryColor from whitelabeling settings and related components for cleaner configuration 2026-03-10 02:03:34 -06:00
Mauricio Siu
2e03cf3d48 refactor: implement safe URL validation for whitelabeling settings in both client and server schemas 2026-03-10 00:55:01 -06:00
Mauricio Siu
33532d3cf7 refactor: update whitelabeling hooks and API usage for improved access control and consistency 2026-03-10 00:47:30 -06:00
autofix-ci[bot]
a6999b1cf2 [autofix.ci] apply automated fixes 2026-03-10 06:32:56 +00:00
Mauricio Siu
f5d18d6f9b refactor: replace adminProcedure with enterpriseProcedure in whitelabeling router for enhanced access control 2026-03-10 00:32:08 -06:00
Mauricio Siu
e3ff7ef3e3 feat: add whitelabelingConfig column to webServerSettings table and update related metadata 2026-03-10 00:28:52 -06:00
Mauricio Siu
b84bc9b7c6 feat: implement whitelabeling features including settings, preview, and provider components 2026-03-10 00:27:58 -06:00
Jiří Vrba
de201d0b0a Add aria-label to webhook URL badge 2026-03-09 10:00:08 +01:00
autofix-ci[bot]
6866e2b63a [autofix.ci] apply automated fixes 2026-03-09 08:49:06 +00:00
Jiří Vrba
3e4a1b92eb Code review fixes 2026-03-09 09:48:37 +01:00
Jiří Vrba
b9ca6ea9db Code review fixes 2026-03-09 09:38:00 +01:00
Jiří Vrba
f1d4543d5e Code review fixes 2026-03-09 09:33:30 +01:00
autofix-ci[bot]
d8c7c1eaf4 [autofix.ci] apply automated fixes 2026-03-09 08:28:35 +00:00
Jiří Vrba
4330d7bd99 feat(deployments): Add option to copy webhook url by clicking on it 2026-03-09 09:25:41 +01:00
Mauricio Siu
6e67864204 Merge pull request #3951 from Dokploy/3948-unhandled-rejection-in-gettrustedorigins-crashes-server-on-db-connection-failure
fix: add error handling to trusted origins retrieval in admin service
2026-03-08 23:52:54 -06:00
Mauricio Siu
2102840bb9 fix: add error handling to trusted origins retrieval in admin service 2026-03-08 23:48:51 -06:00
Mauricio Siu
30f061e774 Merge pull request #3947 from Dokploy/3896-application-monitor-problem
fix: enhance container metrics query to support wildcard matching for…
2026-03-08 16:17:14 -06:00
Mauricio Siu
c00aa6acbf fix: enhance container metrics query to support wildcard matching for container names 2026-03-08 16:16:45 -06:00
Mauricio Siu
8e9ab98a7a Merge pull request #3940 from Dokploy/3806-bug-traefik-and-dokploy-fails-to-start-when-port-8080-is-already-in-use-service-crash
fix: improve port conflict detection by enhancing error messages and …
2026-03-08 03:09:18 -06:00
Mauricio Siu
ce82e2322b fix: improve port conflict detection by enhancing error messages and adding host-level service checks 2026-03-08 03:08:38 -06:00
Mauricio Siu
ec7df05990 Merge pull request #3939 from Dokploy/3827-bulk-deploy-fails-silently-when-deploying-from-docker-image
fix: update success message for service deployment to reflect queued …
2026-03-08 02:53:11 -06:00
Mauricio Siu
75a4e8e8ef fix: update success message for service deployment to reflect queued status 2026-03-08 02:52:46 -06:00
Mauricio Siu
b4319c7ea2 Bump version from v0.28.4 to v0.28.5 2026-03-08 02:46:55 -06:00
Mauricio Siu
e9787b753d Merge pull request #3934 from Dokploy/feat/use-appname-on-backups-folder
Feat/use appname on backups folder
2026-03-07 23:44:08 -06:00
Mauricio Siu
b419294b09 fix: add --drop option to mongorestore command for improved data restoration https://github.com/Dokploy/dokploy/issues/2713 2026-03-07 23:38:58 -06:00
Mauricio Siu
922b4d58f1 refactor: enhance backup functionality by incorporating appName and serviceName for S3 bucket paths 2026-03-07 23:32:41 -06:00
Mauricio Siu
dc8ff78ee5 Merge pull request #3931 from Dokploy/3928-foreign-key-constraint-violation-on-git_provider-during-github-setup-userid-is-empty---v0284
refactor: replace authClient with api.user.session.useQuery in multip…
2026-03-07 18:23:29 -06:00
Mauricio Siu
735c9952d8 chore: import authClient in show-users component for enhanced authentication handling 2026-03-07 18:14:30 -06:00
Mauricio Siu
21821295e3 chore: remove console.log for session in AddGithubProvider component to clean up code 2026-03-07 18:10:35 -06:00
Mauricio Siu
a8467e80e8 refactor: replace authClient with api.user.session.useQuery in multiple components for improved session management 2026-03-07 18:02:25 -06:00
Mauricio Siu
95e14b4199 Merge pull request #3930 from Dokploy/3924-docker-composeyml-excessive-alias-count-indicates-a-resource-exhaustion-attack
feat: add maxAliasCount option to parse function for improved Docker …
2026-03-07 17:44:35 -06:00
Mauricio Siu
076262e479 feat: add maxAliasCount option to parse function for improved Docker Compose file handling 2026-03-07 17:44:01 -06:00
Mauricio Siu
c4f4db3ebc Merge pull request #3921 from Dokploy/3789-mongodb-restore-failed-with-gzip-backupsqlgz-no-such-file-or-directory-error
feat: include backup file in restoreComposeBackup function for improv…
2026-03-07 02:38:54 -06:00
Mauricio Siu
4882bd25ad feat: include backup file in restoreComposeBackup function for improved restore process 2026-03-07 02:38:29 -06:00
Mauricio Siu
7a8f2e53d5 Merge pull request #3920 from Dokploy/3286-azure-openai-endpoint-not-working
fix: prevent doubled /v1/ suffix in Azure OpenAI-compatible URLs
2026-03-07 02:33:23 -06:00
Mauricio Siu
50182a8048 fix: prevent doubled /v1/ suffix in Azure OpenAI-compatible URLs 2026-03-07 02:32:47 -06:00
Mauricio Siu
35d35028f6 Merge pull request #3919 from Dokploy/3855-instead-of-keeping-x-latest-backups-all-database-dokploy-web-server-backups-are-deleted
refactor: update backup file paths to include app name for better org…
2026-03-07 01:55:40 -06:00
Mauricio Siu
a5a4a1a818 refactor: update backup file paths to include app name for better organization 2026-03-07 01:48:11 -06:00
Mauricio Siu
c106d13ab5 Merge pull request #3918 from Dokploy/2686-volume-backups-delete-other-volume-backups
refactor: enhance volume backup path handling to ensure proper prefix…
2026-03-07 01:23:58 -06:00
Mauricio Siu
808001d8de refactor: enhance volume backup path handling to ensure proper prefix usage 2026-03-07 01:22:53 -06:00
Mauricio Siu
ce24eadbb4 Merge pull request #3917 from Dokploy/3752-an-error-have-occured-deployment-not-found
refactor: streamline deployment cleanup by consolidating removeLastTe…
2026-03-07 00:53:28 -06:00
Mauricio Siu
b87f8cc5d8 refactor: streamline deployment cleanup by consolidating removeLastTenDeployments calls 2026-03-07 00:51:28 -06:00
Mauricio Siu
f650200771 Merge pull request #3915 from Dokploy/3775-volume-backup-marked-as-failed-due-to-email-error-450-the-html-field-contains-invalid-input
fix: add error handling for volume backup notification sending
2026-03-07 00:41:54 -06:00
autofix-ci[bot]
f961dc6e7a [autofix.ci] apply automated fixes 2026-03-07 06:41:44 +00:00
Mauricio Siu
4be25da185 fix: add error handling for volume backup notification sending 2026-03-07 00:41:14 -06:00
Mauricio Siu
675c1d7a7d Merge pull request #3914 from Dokploy/3900-local-domains-fetch-failure-for-git-providers-when-using-local-lan-domains
refactor: update Gitea and GitLab URL handling to prioritize internal…
2026-03-07 00:34:37 -06:00
autofix-ci[bot]
28cc361c47 [autofix.ci] apply automated fixes 2026-03-07 06:34:27 +00:00
Mauricio Siu
cedec5239f refactor: update Gitea and GitLab URL handling to prioritize internal URLs if available 2026-03-07 00:33:54 -06:00
Mauricio Siu
2f4cbbd3ac Merge pull request #3913 from Dokploy/3905-isolated-deployment-swarm---network-error
fix: update Docker network creation command to specify driver for sta…
2026-03-07 00:22:40 -06:00
Mauricio Siu
38b20450dc fix: update Docker network creation command to specify driver for stack deployments 2026-03-07 00:21:29 -06:00
Mauricio Siu
49f43ab3fb Merge pull request #3912 from Dokploy/3820-compose-file-editor-cmdf-search-no-longer-works-regression
Update dependencies in pnpm-lock.yaml and package.json for @codemirro…
2026-03-06 23:20:20 -06:00
Mauricio Siu
2eae756cec Update dependencies in pnpm-lock.yaml and package.json for @codemirror packages
- Added @codemirror/search version 6.6.0.
- Updated @codemirror/view to version 6.39.15 across multiple files.
- Adjusted imports in code-editor.tsx to include search functionality.

This update ensures compatibility with the latest features and improvements in the CodeMirror library.
2026-03-06 23:18:29 -06:00
Mauricio Siu
70c261d021 Update packages/server/src/constants/index.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-06 11:43:57 -06:00
Mauricio Siu
9ae2ebff46 Bump version from v0.28.3 to v0.28.4 2026-03-06 08:27:12 -06:00
Mauricio Siu
8ce880d108 Merge pull request #3899 from Dokploy/3819-preview-deployments-incorrectly-inherit-www-redirect
fix: skip redirect middleware for preview deployments to prevent wild…
2026-03-05 11:12:12 -06:00
Mauricio Siu
34304526b1 fix: skip redirect middleware for preview deployments to prevent wildcard subdomain inheritance 2026-03-05 11:08:31 -06:00
Mauricio Siu
a16c4c1294 Merge pull request #3898 from Dokploy/3850-zod-validation-on-undefined-default-values
feat: add enableSubmodules and update watchPaths in application schema
2026-03-05 10:49:47 -06:00
Mauricio Siu
d1c4ac20e3 feat: add enableSubmodules and update watchPaths in application schema 2026-03-05 10:48:47 -06:00
Mauricio Siu
0195119a86 Merge pull request #3894 from Dokploy/3888-deploy-error-client-version-153-is-too-new-on-synology-920
feat: enhance Docker configuration with environment variables for API…
2026-03-05 00:47:46 -06:00
Mauricio Siu
48a577e792 feat: enhance Docker configuration with environment variables for API version, host, and port 2026-03-05 00:46:13 -06:00
Mauricio Siu
bf7a75dd9f Merge pull request #3882 from aak-lear/fix/rollback-registry-auth
fix: add docker login before rollback and fix execAsyncRemote argument order
2026-03-04 22:11:04 -06:00
Mauricio Siu
d316aa4401 Merge pull request #3893 from Dokploy/3853-web-server-backup-fails-when-unreadable-files-unix-sockets-named-pipes-exist-under-etcdokploy
fix: update rsync command in web-server backup to exclude special fil…
2026-03-04 21:37:15 -06:00
Mauricio Siu
f1b2cc35b3 fix: update rsync command in web-server backup to exclude special files and devices 2026-03-04 21:21:46 -06:00
lear
d2fabc998d refactor: reuse safeDockerLoginCommand from registry.ts instead of duplicating shEscape 2026-03-04 12:45:57 +03:00
lear
7185047eb7 fix: add docker login before rollback and fix execAsyncRemote argument order 2026-03-04 11:07:42 +03:00
Mauricio Siu
7121fbe50a Merge pull request #3881 from Dokploy/3864-file-mount-content-not-updated-on-host-when-edited-in-advanced-tab-ui-wordpress-service
refactor: simplify createMount mutation by returning the promise dire…
2026-03-03 22:59:32 -06:00
Mauricio Siu
36cf3a69fc refactor: simplify createMount mutation by returning the promise directly
Updated the createMount mutation to return the promise from createMount directly, enhancing readability. Additionally, adjusted the serviceType schema definition for clarity by removing the default value assignment.
2026-03-03 22:55:46 -06:00
Mauricio Siu
c34a01a173 Merge pull request #3880 from Dokploy/3876-auth-session-ui-not-updating-after-profile-picture-change
refactor: replace authClient with api.organization.active for active …
2026-03-03 22:39:04 -06:00
Mauricio Siu
9ac147a140 refactor: replace authClient with api.organization.active for active organization queries
Updated components to use the new API method for fetching the active organization, improving consistency across the codebase. This change enhances maintainability and aligns with recent API updates.
2026-03-03 22:37:42 -06:00
Mauricio Siu
20f79ac655 fix: update import statements to include file extensions for consistency 2026-03-03 15:35:37 -06:00
191 changed files with 27314 additions and 6144 deletions

View File

@@ -1,4 +1,4 @@
import { logger } from "./logger";
import { logger } from "./logger.js";
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";

View File

@@ -9,7 +9,7 @@ import {
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema";
import type { DeployJob } from "./schema.js";
export const deploy = async (job: DeployJob) => {
try {

View File

@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkPermission } = await import("@dokploy/server/services/permission");
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("static roles bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { deployment: ["read"] }),
).resolves.toBeUndefined();
});
it("admin bypasses backup.create", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkPermission(ctx, { backup: ["create"] }),
).resolves.toBeUndefined();
});
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, {
deployment: ["read"],
backup: ["create"],
domain: ["delete"],
}),
).resolves.toBeUndefined();
});
});
describe("static roles validate free-tier resources", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member fails project.create (no legacy override)", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { project: ["create"] }),
).rejects.toThrow();
});
it("member passes service.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails service.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { service: ["create"] }),
).rejects.toThrow();
});
});
describe("legacy boolean overrides for member", () => {
it("member passes project.create with canCreateProjects=true", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
await expect(
checkPermission(ctx, { project: ["create"] }),
).resolves.toBeUndefined();
});
it("member passes docker.read with canAccessToDocker=true", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
await expect(
checkPermission(ctx, { docker: ["read"] }),
).resolves.toBeUndefined();
});
it("member fails docker.read with canAccessToDocker=false", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from "vitest";
import {
enterpriseOnlyResources,
statements,
} from "@dokploy/server/lib/access-control";
const FREE_TIER_RESOURCES = [
"organization",
"member",
"invitation",
"team",
"ac",
"project",
"service",
"environment",
"docker",
"sshKeys",
"gitProviders",
"traefikFiles",
"api",
];
const ENTERPRISE_RESOURCES = [
"volume",
"deployment",
"envVars",
"projectEnvVars",
"environmentEnvVars",
"server",
"registry",
"certificate",
"backup",
"volumeBackup",
"schedule",
"domain",
"destination",
"notification",
"logs",
"monitoring",
"auditLog",
];
describe("enterpriseOnlyResources set", () => {
it("contains all enterprise resources", () => {
for (const resource of ENTERPRISE_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(true);
}
});
it("does NOT contain free-tier resources", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("every resource in statements is either free or enterprise", () => {
const allResources = Object.keys(statements);
for (const resource of allResources) {
const isFree = FREE_TIER_RESOURCES.includes(resource);
const isEnterprise = enterpriseOnlyResources.has(resource);
expect(isFree || isEnterprise).toBe(true);
}
});
it("free and enterprise sets don't overlap", () => {
for (const resource of FREE_TIER_RESOURCES) {
expect(enterpriseOnlyResources.has(resource)).toBe(false);
}
});
it("all statement resources are accounted for", () => {
const allResources = Object.keys(statements);
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
for (const resource of allResources) {
expect(categorized).toContain(resource);
}
});
});

View File

@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
overrides: Record<string, boolean> = {},
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
canCreateProjects: overrides.canCreateProjects ?? false,
canDeleteProjects: overrides.canDeleteProjects ?? false,
canCreateServices: overrides.canCreateServices ?? false,
canDeleteServices: overrides.canDeleteServices ?? false,
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
canAccessToDocker: overrides.canAccessToDocker ?? false,
canAccessToAPI: overrides.canAccessToAPI ?? false,
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const { enterpriseOnlyResources, statements } = await import(
"@dokploy/server/lib/access-control"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("enterprise resources for static roles", () => {
it("owner gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("admin gets true for all enterprise resources", async () => {
memberToReturn = mockMemberData("admin");
const perms = await resolvePermissions(ctx);
for (const resource of enterpriseOnlyResources) {
const actions = statements[resource as keyof typeof statements];
for (const action of actions) {
expect((perms as any)[resource][action]).toBe(true);
}
}
});
it("member gets true for service-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.deployment.read).toBe(true);
expect(perms.deployment.create).toBe(true);
expect(perms.domain.read).toBe(true);
expect(perms.backup.read).toBe(true);
expect(perms.logs.read).toBe(true);
expect(perms.monitoring.read).toBe(true);
});
it("member gets false for org-level enterprise resources", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
expect(perms.notification.read).toBe(false);
expect(perms.auditLog.read).toBe(false);
});
});
describe("free-tier resources for member", () => {
it("member gets service.read=true", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.service.read).toBe(true);
});
it("member gets project.create=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(false);
});
it("member gets project.create=true with canCreateProjects", async () => {
memberToReturn = mockMemberData("member", { canCreateProjects: true });
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
});
it("member gets docker.read=false without legacy override", async () => {
memberToReturn = mockMemberData("member");
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(false);
});
it("member gets docker.read=true with canAccessToDocker", async () => {
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
const perms = await resolvePermissions(ctx);
expect(perms.docker.read).toBe(true);
});
});
describe("free-tier resources for owner", () => {
it("owner gets all free-tier permissions as true", async () => {
memberToReturn = mockMemberData("owner");
const perms = await resolvePermissions(ctx);
expect(perms.project.create).toBe(true);
expect(perms.project.delete).toBe(true);
expect(perms.service.create).toBe(true);
expect(perms.service.read).toBe(true);
expect(perms.service.delete).toBe(true);
expect(perms.docker.read).toBe(true);
expect(perms.traefikFiles.read).toBe(true);
expect(perms.traefikFiles.write).toBe(true);
});
});

View File

@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
accessedServices: string[] = [],
accessedProjects: string[] = [],
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects,
accessedServices,
accessedEnvironments: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("checkServicePermissionAndAccess", () => {
it("owner bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("owner", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("admin bypasses accessedServices check", async () => {
memberToReturn = mockMemberData("admin", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
backup: ["create"],
}),
).resolves.toBeUndefined();
});
it("member with access to service passes", async () => {
memberToReturn = mockMemberData("member", ["service-123"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).resolves.toBeUndefined();
});
it("member WITHOUT access to service fails", async () => {
memberToReturn = mockMemberData("member", ["other-service"]);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
deployment: ["read"],
}),
).rejects.toThrow("You don't have access to this service");
});
it("member with empty accessedServices fails", async () => {
memberToReturn = mockMemberData("member", []);
await expect(
checkServicePermissionAndAccess(ctx, "service-123", {
domain: ["delete"],
}),
).rejects.toThrow("You don't have access to this service");
});
});
describe("checkServiceAccess", () => {
it("member with service access passes read check", async () => {
memberToReturn = mockMemberData("member", ["app-1"]);
await expect(
checkServiceAccess(ctx, "app-1", "read"),
).resolves.toBeUndefined();
});
it("member without service access fails read check", async () => {
memberToReturn = mockMemberData("member", []);
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
"You don't have access to this service",
);
});
it("owner bypasses all access checks", async () => {
memberToReturn = mockMemberData("owner", [], []);
await expect(
checkServiceAccess(ctx, "project-1", "create"),
).resolves.toBeUndefined();
});
});

View File

@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
urlCallback: "",
},
},
whitelabelingConfig: {
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,

View File

@@ -15,13 +15,17 @@ interface Props {
}
export const ShowTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.traefikFiles.read ?? false;
const { data, isPending } = api.application.readTraefikConfig.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
{ enabled: !!applicationId && canRead },
);
if (!canRead) return null;
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between">

View File

@@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => {
};
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.traefikFiles.write ?? false;
const [open, setOpen] = useState(false);
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
const { data, refetch } = api.application.readTraefikConfig.useQuery(
@@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}
}}
>
<DialogTrigger asChild>
<Button isLoading={isPending}>Modify</Button>
</DialogTrigger>
{canWrite && (
<DialogTrigger asChild>
<Button isLoading={isPending}>Modify</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Update traefik config</DialogTitle>

View File

@@ -21,6 +21,13 @@ interface Props {
}
export const ShowVolumes = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.volume.read ?? false;
const canCreate = permissions?.volume.create ?? false;
const canDelete = permissions?.volume.delete ?? false;
if (!canRead) return null;
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
@@ -50,7 +57,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
</CardDescription>
</div>
{data && data?.mounts.length > 0 && (
{canCreate && data && data?.mounts.length > 0 && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
@@ -63,9 +70,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
{canCreate && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
)}
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
@@ -130,38 +139,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
</div>
</div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType={type}
/>
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
})
.then(() => {
refetch();
toast.success("Volume deleted successfully");
{canCreate && (
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType={type}
/>
)}
{canDelete && (
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
})
.catch(() => {
toast.error("Error deleting volume");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
.then(() => {
refetch();
toast.success("Volume deleted successfully");
})
.catch(() => {
toast.error("Error deleting volume");
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import {
ChevronDown,
ChevronUp,
Clock,
Copy,
Loader2,
RefreshCcw,
RocketIcon,
@@ -10,6 +11,7 @@ import {
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import copy from "copy-to-clipboard";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -97,6 +99,12 @@ export const ShowDeployments = ({
new Set(),
);
const webhookUrl = useMemo(
() =>
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
[url, refreshToken, type],
);
const MAX_DESCRIPTION_LENGTH = 200;
const truncateDescription = (description: string): string => {
@@ -224,11 +232,27 @@ export const ShowDeployments = ({
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy${
type === "compose" ? "/compose" : ""
}/${refreshToken}`}
</span>
<Badge
role="button"
tabIndex={0}
aria-label="Copy webhook URL to clipboard"
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
variant="outline"
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
copy(webhookUrl);
toast.success("Copied to clipboard.");
}
}}
onClick={() => {
copy(webhookUrl);
toast.success("Copied to clipboard.");
}}
>
{webhookUrl}
<Copy className="h-4 w-4 ml-2" />
</Badge>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />
)}

View File

@@ -50,6 +50,9 @@ interface Props {
}
export const ShowDomains = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canCreateDomain = permissions?.domain.create ?? false;
const canDeleteDomain = permissions?.domain.delete ?? false;
const { data: application } =
type === "application"
? api.application.one.useQuery(
@@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
{canCreateDomain && data && data?.length > 0 && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
@@ -173,13 +176,15 @@ export const ShowDomains = ({ id, type }: Props) => {
To access the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
</div>
{canCreateDomain && (
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => {
}
/>
)}
<AddDomain
id={id}
type={type}
domainId={item.domainId}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
{canCreateDomain && (
<AddDomain
id={id}
type={type}
domainId={item.domainId}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
<div className="w-full break-all">

View File

@@ -36,6 +36,8 @@ interface Props {
}
export const ShowEnvironment = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
@@ -185,25 +187,27 @@ PORT=3000
)}
/>
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<Button
type="button"
variant="outline"
onClick={handleCancel}
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Cancel
Save
</Button>
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</div>
)}
</form>
</Form>
</CardContent>

View File

@@ -31,6 +31,8 @@ interface Props {
}
export const ShowEnvironment = ({ applicationId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canWrite = permissions?.envVars.write ?? false;
const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation();
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canWrite}
/>
</FormControl>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
{canWrite && (
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
)}
<Button
isLoading={isPending}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>
</div>
)}
</form>
</Form>
</Card>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>

View File

@@ -30,6 +30,9 @@ interface Props {
export const ShowGeneralApplication = ({ applicationId }: Props) => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
{canDeploy && (
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete
build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{data?.applicationStatus === "idle" ? (
{canDeploy && data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip>
</Button>
</DialogAction>
) : (
) : canDeploy ? (
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip>
</Button>
</DialogAction>
)}
) : null}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
autoDeploy: enabled,
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
})
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
})
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
</CardContent>
</Card>
<ShowProviderForm applicationId={applicationId} />

View File

@@ -46,6 +46,8 @@ interface Props {
}
export const DeleteService = ({ id, type }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDelete = permissions?.service.delete ?? false;
const [isOpen, setIsOpen] = useState(false);
const queryMap = {
@@ -123,6 +125,8 @@ export const DeleteService = ({ id, type }: Props) => {
data?.applicationStatus === "running") ||
(data && "composeStatus" in data && data?.composeStatus === "running");
if (!canDelete) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>

View File

@@ -19,6 +19,9 @@ interface Props {
}
export const ComposeActions = ({ composeId }: Props) => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const canUpdateService = permissions?.service.create ?? false;
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
@@ -35,162 +38,169 @@ export const ComposeActions = ({ composeId }: Props) => {
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
{canDeploy && (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await start({
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error starting compose");
toast.error("Error deploying compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
)}
{canDeploy && (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await stop({
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
toast.error("Error reloading compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy &&
(data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
@@ -205,27 +215,29 @@ export const ComposeActions = ({ composeId }: Props) => {
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
composeId,
autoDeploy: enabled,
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
</div>
);
};

View File

@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
type AddComposeFile = z.infer<typeof AddComposeFile>;
export const ComposeFileEditor = ({ composeId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canUpdate = permissions?.service.create ?? false;
const utils = api.useUtils();
const { data, refetch } = api.compose.one.useQuery(
{
@@ -164,14 +166,16 @@ services:
</Form>
<div className="flex justify-between flex-col lg:flex-row gap-2">
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
<Button
type="submit"
form="hook-form-save-compose-file"
isLoading={isPending}
className="lg:w-fit w-full"
>
Save
</Button>
{canUpdate && (
<Button
type="submit"
form="hook-form-save-compose-file"
isLoading={isPending}
className="lg:w-fit w-full"
>
Save
</Button>
)}
</div>
</div>
</>

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>

View File

@@ -45,10 +45,12 @@ import {
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type User = typeof authClient.$Infer.Session.user;
export const ImpersonationBar = () => {
const { config: whitelabeling } = useWhitelabeling();
const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isImpersonating, setIsImpersonating] = useState(false);
@@ -180,7 +182,10 @@ export const ImpersonationBar = () => {
)}
>
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
<Logo className="w-10 h-10" />
<Logo
className="w-10 h-10"
logoUrl={whitelabeling?.logoUrl || undefined}
/>
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
<Popover open={open} onOpenChange={setOpen}>

View File

@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mariadb.one.useQuery(
{
mariadbId,
@@ -72,154 +74,33 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction
title="Deploy Mariadb"
description="Are you sure you want to deploy this mariadb?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<DialogAction
title="Reload Mariadb"
description="Are you sure you want to reload this mariadb?"
type="default"
onClick={async () => {
await reload({
mariadbId: mariadbId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mariadb reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
{data?.applicationStatus === "idle" ? (
{canDeploy && (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Mariadb"
description="Are you sure you want to start this mariadb?"
title="Deploy Mariadb"
description="Are you sure you want to deploy this mariadb?"
type="default"
onClick={async () => {
await start({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mariadb");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="secondary"
isLoading={isStarting}
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MariaDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
onClick={async () => {
await stop({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mariadb");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
<p>Downloads and sets up the MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
@@ -227,6 +108,132 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
</DialogAction>
</TooltipProvider>
)}
{canDeploy && (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Reload Mariadb"
description="Are you sure you want to reload this mariadb?"
type="default"
onClick={async () => {
await reload({
mariadbId: mariadbId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mariadb reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
)}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Mariadb"
description="Are you sure you want to start this mariadb?"
type="default"
onClick={async () => {
await start({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MariaDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
onClick={async () => {
await stop({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mariadb");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
))}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}

View File

@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralMongo = ({ mongoId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mongo.one.useQuery(
{
mongoId,
@@ -73,153 +75,158 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction
title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Mongo"
description="Are you sure you want to reload this mongo?"
type="default"
onClick={async () => {
await reload({
mongoId: mongoId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mongo reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mongo");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
{canDeploy && (
<DialogAction
title="Start Mongo"
description="Are you sure you want to start this mongo?"
title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?"
type="default"
onClick={async () => {
await start({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mongo");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="secondary"
isLoading={isStarting}
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MongoDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mongo"
description="Are you sure you want to stop this mongo?"
onClick={async () => {
await stop({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
<p>Downloads and sets up the MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Reload Mongo"
description="Are you sure you want to reload this mongo?"
type="default"
onClick={async () => {
await reload({
mongoId: mongoId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mongo reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mongo");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Mongo"
description="Are you sure you want to start this mongo?"
type="default"
onClick={async () => {
await start({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mongo");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MongoDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mongo"
description="Are you sure you want to stop this mongo?"
onClick={async () => {
await stop({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}

View File

@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralMysql = ({ mysqlId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.mysql.one.useQuery(
{
mysqlId,
@@ -71,153 +73,158 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction
title="Deploy MySQL"
description="Are you sure you want to deploy this mysql?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload MySQL"
description="Are you sure you want to reload this mysql?"
type="default"
onClick={async () => {
await reload({
mysqlId: mysqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("MySQL reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading MySQL");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
{canDeploy && (
<DialogAction
title="Start MySQL"
description="Are you sure you want to start this mysql?"
title="Deploy MySQL"
description="Are you sure you want to deploy this mysql?"
type="default"
onClick={async () => {
await start({
mysqlId: mysqlId,
})
.then(() => {
toast.success("MySQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting MySQL");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="secondary"
isLoading={isStarting}
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MySQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop MySQL"
description="Are you sure you want to stop this mysql?"
onClick={async () => {
await stop({
mysqlId: mysqlId,
})
.then(() => {
toast.success("MySQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping MySQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
<p>Downloads and sets up the MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Reload MySQL"
description="Are you sure you want to reload this mysql?"
type="default"
onClick={async () => {
await reload({
mysqlId: mysqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("MySQL reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading MySQL");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<DialogAction
title="Start MySQL"
description="Are you sure you want to start this mysql?"
type="default"
onClick={async () => {
await start({
mysqlId: mysqlId,
})
.then(() => {
toast.success("MySQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting MySQL");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MySQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop MySQL"
description="Are you sure you want to stop this mysql?"
onClick={async () => {
await stop({
mysqlId: mysqlId,
})
.then(() => {
toast.success("MySQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping MySQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}

View File

@@ -24,7 +24,6 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const organizationSchema = z.object({
@@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) {
const { mutateAsync, isPending } = organizationId
? api.organization.update.useMutation()
: api.organization.create.useMutation();
const { refetch: refetchActiveOrganization } =
authClient.useActiveOrganization();
const form = useForm<OrganizationFormValues>({
resolver: zodResolver(organizationSchema),
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
utils.organization.all.invalidate();
if (organizationId) {
utils.organization.one.invalidate({ organizationId });
refetchActiveOrganization();
utils.organization.active.invalidate();
}
setOpen(false);
})

View File

@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralPostgres = ({ postgresId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.postgres.one.useQuery(
{
postgresId: postgresId,
@@ -73,153 +75,162 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider disableHoverableContent={false}>
<DialogAction
title="Deploy PostgreSQL"
description="Are you sure you want to deploy this postgres?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload PostgreSQL"
description="Are you sure you want to reload this postgres?"
type="default"
onClick={async () => {
await reload({
postgresId: postgresId,
appName: data?.appName || "",
})
.then(() => {
toast.success("PostgreSQL reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading PostgreSQL");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the PostgreSQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
{canDeploy && (
<DialogAction
title="Start PostgreSQL"
description="Are you sure you want to start this postgres?"
title="Deploy PostgreSQL"
description="Are you sure you want to deploy this postgres?"
type="default"
onClick={async () => {
await start({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting PostgreSQL");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="secondary"
isLoading={isStarting}
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the PostgreSQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop PostgreSQL"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping PostgreSQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running PostgreSQL database</p>
<p>Downloads and sets up the PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Reload PostgreSQL"
description="Are you sure you want to reload this postgres?"
type="default"
onClick={async () => {
await reload({
postgresId: postgresId,
appName: data?.appName || "",
})
.then(() => {
toast.success("PostgreSQL reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading PostgreSQL");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Restart the PostgreSQL service without rebuilding
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<DialogAction
title="Start PostgreSQL"
description="Are you sure you want to start this postgres?"
type="default"
onClick={async () => {
await start({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting PostgreSQL");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the PostgreSQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop PostgreSQL"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping PostgreSQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Stop the currently running PostgreSQL database
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}

View File

@@ -57,19 +57,13 @@ export const AdvancedEnvironmentSelector = ({
const [description, setDescription] = useState("");
// Get current user's permissions
const { data: currentUser } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
// Check if user can create environments
const canCreateEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canCreateEnvironments === true;
const canCreateEnvironments = !!permissions?.environment.create;
// Check if user can delete environments
const canDeleteEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canDeleteEnvironments === true;
const canDeleteEnvironments = !!permissions?.environment.delete;
const haveServices =
selectedEnvironment &&

View File

@@ -39,6 +39,9 @@ interface Props {
}
export const EnvironmentVariables = ({ environmentId, children }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.environmentEnvVars.read ?? false;
const canWrite = permissions?.environmentEnvVars.write ?? false;
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } =
@@ -97,6 +100,10 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
};
}, [form, onSubmit, isPending, isOpen]);
if (!canRead) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -141,6 +148,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
<CodeEditor
lineWrapping
language="properties"
readOnly={!canWrite}
wrapperClassName="h-[35rem] font-mono"
placeholder={`NODE_ENV=development
DATABASE_URL=postgresql://localhost:5432/mydb
@@ -157,11 +165,13 @@ API_KEY=your-api-key-here
</FormItem>
)}
/>
<DialogFooter>
<Button isLoading={isPending} type="submit">
Update
</Button>
</DialogFooter>
{canWrite && (
<DialogFooter>
<Button isLoading={isPending} type="submit">
Update
</Button>
</DialogFooter>
)}
</form>
</Form>
</div>

View File

@@ -39,6 +39,9 @@ interface Props {
}
export const ProjectEnvironment = ({ projectId, children }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canRead = permissions?.projectEnvVars.read ?? false;
const canWrite = permissions?.projectEnvVars.write ?? false;
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } =
@@ -96,6 +99,10 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
};
}, [form, onSubmit, isPending, isOpen]);
if (!canRead) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -139,6 +146,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
<CodeEditor
lineWrapping
language="properties"
readOnly={!canWrite}
wrapperClassName="h-[35rem] font-mono"
placeholder={`NODE_ENV=production
PORT=3000
@@ -154,11 +162,13 @@ PORT=3000
</FormItem>
)}
/>
<DialogFooter>
<Button isLoading={isPending} type="submit">
Update
</Button>
</DialogFooter>
{canWrite && (
<DialogFooter>
<Button isLoading={isPending} type="submit">
Update
</Button>
</DialogFooter>
)}
</form>
</Form>
</div>

View File

@@ -61,6 +61,7 @@ export const ShowProjects = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isPending } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState(
@@ -168,11 +169,6 @@ export const ShowProjects = () => {
<BreadcrumbSidebar
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
<div className="rounded-xl bg-background shadow-md ">
@@ -186,9 +182,7 @@ export const ShowProjects = () => {
Create and manage your projects
</CardDescription>
</CardHeader>
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateProjects) && (
{permissions?.project.create && (
<div className="">
<HandleProject />
</div>
@@ -361,8 +355,7 @@ export const ShowProjects = () => {
<div
onClick={(e) => e.stopPropagation()}
>
{(auth?.role === "owner" ||
auth?.canDeleteProjects) && (
{permissions?.project.delete && (
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem

View File

@@ -21,6 +21,8 @@ interface Props {
}
export const ShowGeneralRedis = ({ redisId }: Props) => {
const { data: permissions } = api.user.getPermissions.useQuery();
const canDeploy = permissions?.deployment.create ?? false;
const { data, refetch } = api.redis.one.useQuery(
{
redisId,
@@ -72,153 +74,158 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction
title="Deploy Redis"
description="Are you sure you want to deploy this redis?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Redis"
description="Are you sure you want to reload this redis?"
type="default"
onClick={async () => {
await reload({
redisId: redisId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Redis reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Redis");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the Redis service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
{canDeploy && (
<DialogAction
title="Start Redis"
description="Are you sure you want to start this redis?"
title="Deploy Redis"
description="Are you sure you want to deploy this redis?"
type="default"
onClick={async () => {
await start({
redisId: redisId,
})
.then(() => {
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Redis");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="secondary"
isLoading={isStarting}
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the Redis database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Redis"
description="Are you sure you want to stop this redis?"
onClick={async () => {
await stop({
redisId: redisId,
})
.then(() => {
toast.success("Redis stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Redis");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running Redis database</p>
<p>Downloads and sets up the Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && (
<DialogAction
title="Reload Redis"
description="Are you sure you want to reload this redis?"
type="default"
onClick={async () => {
await reload({
redisId: redisId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Redis reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Redis");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the Redis service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy &&
(data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Redis"
description="Are you sure you want to start this redis?"
type="default"
onClick={async () => {
await start({
redisId: redisId,
})
.then(() => {
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Redis");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the Redis database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Redis"
description="Are you sure you want to stop this redis?"
onClick={async () => {
await stop({
redisId: redisId,
})
.then(() => {
toast.success("Redis stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Redis");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
))}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}

View File

@@ -23,7 +23,6 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { StatusTooltip } from "../shared/status-tooltip";
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const { data: session } = authClient.useSession();
const { data: session } = api.user.session.useQuery();
const { data } = api.project.all.useQuery(undefined, {
enabled: !!session,
});

View File

@@ -91,7 +91,10 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const utils = api.useUtils();
const [serverQuantity, setServerQuantity] = useState(3);
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
const [startupServerQuantity, setStartupServerQuantity] = useState(
STARTUP_SERVERS_INCLUDED,
);
const [isAnnual, setIsAnnual] = useState(false);
const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
null,
@@ -111,6 +114,12 @@ export const ShowBilling = () => {
productId: string,
) => {
const stripe = await stripePromise;
const serverQuantity =
tier === "startup"
? startupServerQuantity
: tier === "hobby"
? hobbyServerQuantity
: hobbyServerQuantity;
if (data && data.subscriptions.length === 0) {
createCheckoutSession({
tier,
@@ -679,7 +688,7 @@ export const ShowBilling = () => {
<p className="text-2xl font-semibold text-foreground">
$
{calculatePriceHobby(
serverQuantity,
hobbyServerQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
@@ -692,7 +701,8 @@ export const ShowBilling = () => {
<p className="text-xs text-muted-foreground mt-2">
$
{(
calculatePriceHobby(serverQuantity, true) / 12
calculatePriceHobby(hobbyServerQuantity, true) /
12
).toFixed(2)}
/mo
</p>
@@ -724,19 +734,19 @@ export const ShowBilling = () => {
Servers:
</span>
<Button
disabled={serverQuantity <= 1}
disabled={hobbyServerQuantity <= 1}
variant="outline"
size="icon"
onClick={() =>
setServerQuantity((q) => Math.max(1, q - 1))
setHobbyServerQuantity((q) => Math.max(1, q - 1))
}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
value={hobbyServerQuantity}
onChange={(e) =>
setServerQuantity(
setHobbyServerQuantity(
Math.max(
1,
Number(
@@ -750,7 +760,7 @@ export const ShowBilling = () => {
<Button
variant="outline"
size="icon"
onClick={() => setServerQuantity((q) => q + 1)}
onClick={() => setHobbyServerQuantity((q) => q + 1)}
>
<PlusIcon className="h-4 w-4" />
</Button>
@@ -775,7 +785,7 @@ export const ShowBilling = () => {
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
disabled={serverQuantity < 1}
disabled={hobbyServerQuantity < 1}
>
Get Started
</Button>
@@ -806,7 +816,7 @@ export const ShowBilling = () => {
<p className="text-2xl font-semibold text-foreground">
$
{calculatePriceStartup(
serverQuantity,
startupServerQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
@@ -819,7 +829,10 @@ export const ShowBilling = () => {
<p className="text-xs text-muted-foreground mt-2">
$
{(
calculatePriceStartup(serverQuantity, true) / 12
calculatePriceStartup(
startupServerQuantity,
true,
) / 12
).toFixed(2)}
/mo
</p>
@@ -856,13 +869,14 @@ export const ShowBilling = () => {
<div className="flex items-center gap-2">
<Button
disabled={
serverQuantity <= STARTUP_SERVERS_INCLUDED
startupServerQuantity <=
STARTUP_SERVERS_INCLUDED
}
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() =>
setServerQuantity((q) =>
setStartupServerQuantity((q) =>
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
)
}
@@ -870,9 +884,9 @@ export const ShowBilling = () => {
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
value={startupServerQuantity}
onChange={(e) =>
setServerQuantity(
setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
@@ -887,7 +901,9 @@ export const ShowBilling = () => {
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setServerQuantity((q) => q + 1)}
onClick={() =>
setStartupServerQuantity((q) => q + 1)
}
>
<PlusIcon className="h-4 w-4" />
</Button>
@@ -917,7 +933,7 @@ export const ShowBilling = () => {
)
}
disabled={
serverQuantity < STARTUP_SERVERS_INCLUDED
startupServerQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
@@ -1009,7 +1025,7 @@ export const ShowBilling = () => {
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(
serverQuantity,
hobbyServerQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
@@ -1018,7 +1034,10 @@ export const ShowBilling = () => {
<p className="text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
calculatePrice(
hobbyServerQuantity,
isAnnual,
) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
@@ -1026,9 +1045,10 @@ export const ShowBilling = () => {
) : (
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
2,
)}{" "}
{calculatePrice(
hobbyServerQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
</p>
)}
@@ -1071,26 +1091,28 @@ export const ShowBilling = () => {
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
{hobbyServerQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
<Button
disabled={serverQuantity <= 1}
disabled={hobbyServerQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
if (hobbyServerQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
setHobbyServerQuantity(
hobbyServerQuantity - 1,
);
}}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
value={hobbyServerQuantity}
onChange={(e) => {
setServerQuantity(
setHobbyServerQuantity(
e.target.value as unknown as number,
);
}}
@@ -1099,7 +1121,9 @@ export const ShowBilling = () => {
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
setHobbyServerQuantity(
hobbyServerQuantity + 1,
);
}}
>
<PlusIcon className="h-4 w-4" />
@@ -1125,7 +1149,7 @@ export const ShowBilling = () => {
onClick={async () => {
handleCheckout("legacy", product.id);
}}
disabled={serverQuantity < 1}
disabled={hobbyServerQuantity < 1}
>
Subscribe
</Button>

View File

@@ -18,6 +18,7 @@ export const ShowCertificates = () => {
const { mutateAsync, isPending: isRemoving } =
api.certificates.remove.useMutation();
const { data, isPending, refetch } = api.certificates.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
@@ -53,7 +54,7 @@ export const ShowCertificates = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any certificates created
</span>
<AddCertificate />
{permissions?.certificate.create && <AddCertificate />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -101,47 +102,52 @@ export const ShowCertificates = () => {
</div>
</div>
<div className="flex flex-row gap-1">
<DialogAction
title="Delete Certificate"
description="Are you sure you want to delete this certificate?"
type="destructive"
onClick={async () => {
await mutateAsync({
certificateId: certificate.certificateId,
})
.then(() => {
toast.success(
"Certificate deleted successfully",
);
refetch();
{permissions?.certificate.delete && (
<div className="flex flex-row gap-1">
<DialogAction
title="Delete Certificate"
description="Are you sure you want to delete this certificate?"
type="destructive"
onClick={async () => {
await mutateAsync({
certificateId:
certificate.certificateId,
})
.catch(() => {
toast.error(
"Error deleting certificate",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success(
"Certificate deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting certificate",
);
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
)}
</div>
</div>
);
})}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddCertificate />
</div>
{permissions?.certificate.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<AddCertificate />
</div>
)}
</div>
)}
</>

View File

@@ -16,6 +16,7 @@ export const ShowRegistry = () => {
const { mutateAsync, isPending: isRemoving } =
api.registry.remove.useMutation();
const { data, isPending, refetch } = api.registry.all.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
@@ -44,7 +45,7 @@ export const ShowRegistry = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any registry configurations
</span>
<HandleRegistry />
{permissions?.registry.create && <HandleRegistry />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -73,45 +74,49 @@ export const ShowRegistry = () => {
registryId={registry.registryId}
/>
<DialogAction
title="Delete Registry"
description="Are you sure you want to delete this registry configuration?"
type="destructive"
onClick={async () => {
await mutateAsync({
registryId: registry.registryId,
})
.then(() => {
toast.success(
"Registry configuration deleted successfully",
);
refetch();
{permissions?.registry.delete && (
<DialogAction
title="Delete Registry"
description="Are you sure you want to delete this registry configuration?"
type="destructive"
onClick={async () => {
await mutateAsync({
registryId: registry.registryId,
})
.catch(() => {
toast.error(
"Error deleting registry configuration",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success(
"Registry configuration deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting registry configuration",
);
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleRegistry />
</div>
{permissions?.registry.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleRegistry />
</div>
)}
</div>
)}
</>

View File

@@ -16,6 +16,7 @@ export const ShowDestinations = () => {
const { data, isPending, refetch } = api.destination.all.useQuery();
const { mutateAsync, isPending: isRemoving } =
api.destination.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
@@ -45,7 +46,7 @@ export const ShowDestinations = () => {
To create a backup it is required to set at least 1
provider.
</span>
<HandleDestinations />
{permissions?.destination.create && <HandleDestinations />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -71,43 +72,49 @@ export const ShowDestinations = () => {
<HandleDestinations
destinationId={destination.destinationId}
/>
<DialogAction
title="Delete Destination"
description="Are you sure you want to delete this destination?"
type="destructive"
onClick={async () => {
await mutateAsync({
destinationId: destination.destinationId,
})
.then(() => {
toast.success(
"Destination deleted successfully",
);
refetch();
{permissions?.destination.delete && (
<DialogAction
title="Delete Destination"
description="Are you sure you want to delete this destination?"
type="destructive"
onClick={async () => {
await mutateAsync({
destinationId: destination.destinationId,
})
.catch(() => {
toast.error("Error deleting destination");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success(
"Destination deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting destination",
);
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDestinations />
</div>
{permissions?.destination.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleDestinations />
</div>
)}
</div>
)}
</>

View File

@@ -12,13 +12,13 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: session } = api.user.session.useQuery();
const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [data?.id, activeOrganization?.id, session?.user?.id]);
}, [activeOrganization?.id, session?.user?.id]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
}
method="post"
>
@@ -131,11 +131,7 @@ export const AddGithubProvider = () => {
Unsure if you already have an app?
</a>
<Button
disabled={
(isOrganization && organizationName.length < 1) ||
!activeOrganization?.id ||
!session?.user?.id
}
disabled={isOrganization && organizationName.length < 1}
type="submit"
className="self-end"
>

View File

@@ -737,6 +737,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
setVisible(false);
await utils.notification.all.invalidate();
if (notificationId) {
await utils.notification.one.invalidate({ notificationId });
}
})
.catch(() => {
toast.error(

View File

@@ -26,6 +26,7 @@ export const ShowNotifications = () => {
const { data, isPending, refetch } = api.notification.all.useQuery();
const { mutateAsync, isPending: isRemoving } =
api.notification.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
@@ -56,7 +57,9 @@ export const ShowNotifications = () => {
To send notifications it is required to set at least 1
provider.
</span>
<HandleNotifications />
{permissions?.notification.create && (
<HandleNotifications />
)}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -126,45 +129,50 @@ export const ShowNotifications = () => {
notificationId={notification.notificationId}
/>
<DialogAction
title="Delete Notification"
description="Are you sure you want to delete this notification?"
type="destructive"
onClick={async () => {
await mutateAsync({
notificationId: notification.notificationId,
})
.then(() => {
toast.success(
"Notification deleted successfully",
);
refetch();
{permissions?.notification.delete && (
<DialogAction
title="Delete Notification"
description="Are you sure you want to delete this notification?"
type="destructive"
onClick={async () => {
await mutateAsync({
notificationId:
notification.notificationId,
})
.catch(() => {
toast.error(
"Error deleting notification",
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success(
"Notification deleted successfully",
);
refetch();
})
.catch(() => {
toast.error(
"Error deleting notification",
);
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleNotifications />
</div>
{permissions?.notification.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleNotifications />
</div>
)}
</div>
)}
</>

View File

@@ -59,6 +59,7 @@ export const ShowServers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: canCreateMoreServers } =
api.stripe.canCreateMoreServers.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
@@ -115,7 +116,7 @@ export const ShowServers = () => {
Start adding servers to deploy your applications
remotely.
</span>
<HandleServers />
{permissions?.server.create && <HandleServers />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -362,66 +363,71 @@ export const ShowServers = () => {
<div className="flex-1" />
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active services
associated with this
server, please delete
them first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
{permissions?.server.delete && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogAction
disabled={!canDelete}
title={
canDelete
? "Delete Server"
: "Server has active services"
}
description={
canDelete ? (
"This will delete the server and all associated data"
) : (
<div className="flex flex-col gap-2">
You can not delete this
server because it has
active services.
<AlertBlock type="warning">
You have active
services associated
with this server,
please delete them
first.
</AlertBlock>
</div>
)
}
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted successfully`,
);
})
.catch((err) => {
toast.error(
err.message,
);
});
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{canDelete
? "Delete Server"
: "Cannot delete - has active services"}
</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
)}
@@ -431,13 +437,15 @@ export const ShowServers = () => {
})}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
{data && data?.length > 0 && (
<div>
<HandleServers />
</div>
)}
</div>
{permissions?.server.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
{data && data?.length > 0 && (
<div>
<HandleServers />
</div>
)}
</div>
)}
</div>
)}
</>

View File

@@ -17,6 +17,7 @@ export const ShowDestinations = () => {
const { data, isPending, refetch } = api.sshKey.all.useQuery();
const { mutateAsync, isPending: isRemoving } =
api.sshKey.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
@@ -46,7 +47,7 @@ export const ShowDestinations = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any SSH keys
</span>
<HandleSSHKeys />
{permissions?.sshKeys.create && <HandleSSHKeys />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -84,43 +85,47 @@ export const ShowDestinations = () => {
<div className="flex flex-row gap-1">
<HandleSSHKeys sshKeyId={sshKey.sshKeyId} />
<DialogAction
title="Delete SSH Key"
description="Are you sure you want to delete this SSH Key?"
type="destructive"
onClick={async () => {
await mutateAsync({
sshKeyId: sshKey.sshKeyId,
})
.then(() => {
toast.success(
"SSH Key deleted successfully",
);
refetch();
{permissions?.sshKeys.delete && (
<DialogAction
title="Delete SSH Key"
description="Are you sure you want to delete this SSH Key?"
type="destructive"
onClick={async () => {
await mutateAsync({
sshKeyId: sshKey.sshKeyId,
})
.catch(() => {
toast.error("Error deleting SSH Key");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
.then(() => {
toast.success(
"SSH Key deleted successfully",
);
refetch();
})
.catch(() => {
toast.error("Error deleting SSH Key");
});
}}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleSSHKeys />
</div>
{permissions?.sshKeys.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleSSHKeys />
</div>
)}
</div>
)}
</>

View File

@@ -32,7 +32,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const addInvitation = z.object({
@@ -40,7 +39,7 @@ const addInvitation = z.object({
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
role: z.enum(["member", "admin"]),
role: z.string().min(1, "Role is required"),
notificationId: z.string().optional(),
});
@@ -49,13 +48,14 @@ type AddInvitation = z.infer<typeof addInvitation>;
export const AddInvitation = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const { mutateAsync: inviteMember, isPending: isInviting } =
api.organization.inviteMember.useMutation();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const { data: customRoles } = api.customRole.all.useQuery();
const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization();
const form = useForm<AddInvitation>({
defaultValues: {
@@ -70,19 +70,15 @@ export const AddInvitation = () => {
}, [form, form.formState.isSubmitSuccessful, form.reset]);
const onSubmit = async (data: AddInvitation) => {
setIsLoading(true);
const result = await authClient.organization.inviteMember({
email: data.email.toLowerCase(),
role: data.role,
organizationId: activeOrganization?.id,
});
try {
const result = await inviteMember({
email: data.email.toLowerCase(),
role: data.role,
});
if (result.error) {
setError(result.error.message || "");
} else {
if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result.data.id,
invitationId: result!.id,
notificationId: data.notificationId || "",
})
.then(() => {
@@ -96,10 +92,11 @@ export const AddInvitation = () => {
}
setError(null);
setOpen(false);
} catch (error: any) {
setError(error.message || "Failed to create invitation");
}
utils.organization.allInvitations.invalidate();
setIsLoading(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -159,6 +156,11 @@ export const AddInvitation = () => {
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
{customRoles?.map((role) => (
<SelectItem key={role.role} value={role.role}>
{role.role}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
@@ -212,7 +214,7 @@ export const AddInvitation = () => {
)}
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isLoading}
isLoading={isInviting}
form="hook-form-add-invitation"
type="submit"
>

View File

@@ -173,9 +173,11 @@ type AddPermissions = z.infer<typeof addPermissions>;
interface Props {
userId: string;
role?: string;
}
export const AddUserPermissions = ({ userId }: Props) => {
export const AddUserPermissions = ({ userId, role }: Props) => {
const isCustomRole = !!role && !["owner", "admin", "member"].includes(role);
const [isOpen, setIsOpen] = useState(false);
const { data: projects } = api.project.allForPermissions.useQuery(undefined, {
enabled: isOpen,
@@ -284,226 +286,237 @@ export const AddUserPermissions = ({ userId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
<FormField
control={form.control}
name="canCreateProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Projects</FormLabel>
<FormDescription>
Allow the user to create projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Projects</FormLabel>
<FormDescription>
Allow the user to delete projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Services</FormLabel>
<FormDescription>
Allow the user to create services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Services</FormLabel>
<FormDescription>
Allow the user to delete services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Environments</FormLabel>
<FormDescription>
Allow the user to create environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Environments</FormLabel>
<FormDescription>
Allow the user to delete environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToTraefikFiles"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Traefik Files</FormLabel>
<FormDescription>
Allow the user to access to the Traefik Tab Files
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToDocker"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Docker</FormLabel>
<FormDescription>
Allow the user to access to the Docker Tab
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToAPI"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to API/CLI</FormLabel>
<FormDescription>
Allow the user to access to the API/CLI
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToSSHKeys"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to SSH Keys</FormLabel>
<FormDescription>
Allow to users to access to the SSH Keys section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToGitProviders"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Git Providers</FormLabel>
<FormDescription>
Allow to users to access to the Git Providers section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{isCustomRole && (
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
This user has a custom role assigned. Capabilities are defined
by the role. You can still manage which projects, environments,
and services they can access below.
</div>
)}
{!isCustomRole && (
<>
<FormField
control={form.control}
name="canCreateProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Projects</FormLabel>
<FormDescription>
Allow the user to create projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Projects</FormLabel>
<FormDescription>
Allow the user to delete projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Services</FormLabel>
<FormDescription>
Allow the user to create services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Services</FormLabel>
<FormDescription>
Allow the user to delete services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Environments</FormLabel>
<FormDescription>
Allow the user to create environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Environments</FormLabel>
<FormDescription>
Allow the user to delete environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToTraefikFiles"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Traefik Files</FormLabel>
<FormDescription>
Allow the user to access to the Traefik Tab Files
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToDocker"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Docker</FormLabel>
<FormDescription>
Allow the user to access to the Docker Tab
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToAPI"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to API/CLI</FormLabel>
<FormDescription>
Allow the user to access to the API/CLI
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToSSHKeys"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to SSH Keys</FormLabel>
<FormDescription>
Allow to users to access to the SSH Keys section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToGitProviders"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Git Providers</FormLabel>
<FormDescription>
Allow to users to access to the Git Providers section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="accessedProjects"

View File

@@ -34,14 +34,14 @@ import {
import { api } from "@/utils/api";
const changeRoleSchema = z.object({
role: z.enum(["admin", "member"]),
role: z.string().min(1),
});
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
interface Props {
memberId: string;
currentRole: "admin" | "member";
currentRole: string;
userEmail: string;
}
@@ -49,6 +49,10 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data: customRoles } = api.customRole.all.useQuery(undefined, {
enabled: isOpen,
});
const { mutateAsync, isError, error, isPending } =
api.organization.updateMemberRole.useMutation();
@@ -125,6 +129,14 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
{customRoles?.map((customRole) => (
<SelectItem
key={customRole.role}
value={customRole.role}
>
{customRole.role}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
@@ -132,6 +144,13 @@ export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
<br />
<strong>Member:</strong> Limited permissions, can be
customized.
{customRoles && customRoles.length > 0 && (
<>
<br />
<strong>Custom roles:</strong> Enterprise-defined
permissions.
</>
)}
<br />
<em className="text-muted-foreground text-xs">
Note: Owner role is intransferible.

View File

@@ -1,6 +1,7 @@
import { format } from "date-fns";
import { Loader2, MoreHorizontal, Users } from "lucide-react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -35,9 +36,19 @@ export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isPending, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: hasValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const utils = api.useUtils();
const { data: session } = authClient.useSession();
const { data: session } = api.user.session.useQuery();
const FREE_ROLES = ["owner", "admin", "member"];
const membersWithCustomRoles = data?.filter(
(member) => !FREE_ROLES.includes(member.role),
);
const hasCustomRolesWithoutLicense =
!hasValidLicense && (membersWithCustomRoles?.length ?? 0) > 0;
return (
<div className="w-full">
@@ -69,6 +80,18 @@ export const ShowUsers = () => {
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
{hasCustomRolesWithoutLicense && (
<AlertBlock type="warning">
You have{" "}
{membersWithCustomRoles?.length === 1
? "1 user"
: `${membersWithCustomRoles?.length} users`}{" "}
assigned to custom roles. Custom roles will not work
without a valid Enterprise license. Please activate your
license or change these users to a free role (Admin or
Member).
</AlertBlock>
)}
<Table>
<TableHeader>
<TableRow>
@@ -89,40 +112,39 @@ export const ShowUsers = () => {
)?.role;
// Owner never has "Edit Permissions" (they're absolute owner)
// Other users can edit permissions if target is not themselves and target is a member
// Other users can edit permissions if target is not themselves and target is a member/custom role
const isStaticAdminOrOwner =
member.role === "owner" || member.role === "admin";
const canEditPermissions =
member.role !== "owner" &&
member.role === "member" &&
!isStaticAdminOrOwner &&
member.user.id !== session?.user?.id;
// Can change role based on hierarchy:
// - Owner: Can change anyone's role (except themselves and other owners)
// - Admin: Can only change member roles (not other admins or owners)
// - Admin: Can only change member/custom roles (not other admins or owners)
// - Owner role is intransferible
const canChangeRole =
member.role !== "owner" &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
member.role !== "admin"));
// Delete/Unlink follow same hierarchy as role changes
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
// - Admin: Can only delete/unlink members (not other admins or owner)
const canDelete =
member.role !== "owner" &&
!isCloud &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
const canDeleteMember =
permissions?.member.delete ?? false;
const canUnlink =
// Self-hosted: "Delete User" removes the user entirely
// Cloud: "Unlink User" removes from the organization only
const canRemove =
member.role !== "owner" &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
member.role !== "admin") ||
(canDeleteMember && !isStaticAdminOrOwner));
const canDelete = canRemove && !isCloud;
const canUnlink = canRemove && !!isCloud;
const hasAnyAction =
canEditPermissions ||
@@ -134,6 +156,11 @@ export const ShowUsers = () => {
<TableRow key={member.id}>
<TableCell className="w-[100px]">
{member.user.email}
{member.user.id === session?.user?.id && (
<span className="text-muted-foreground ml-1">
(You)
</span>
)}
</TableCell>
<TableCell className="text-center">
<Badge
@@ -179,9 +206,7 @@ export const ShowUsers = () => {
{canChangeRole && (
<ChangeRole
memberId={member.id}
currentRole={
member.role as "admin" | "member"
}
currentRole={member.role}
userEmail={member.user.email}
/>
)}
@@ -189,6 +214,7 @@ export const ShowUsers = () => {
{canEditPermissions && (
<AddUserPermissions
userId={member.user.id}
role={member.role}
/>
)}

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import type React from "react";
import { cn } from "@/lib/utils";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
@@ -9,23 +10,28 @@ interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { config: whitelabeling } = useWhitelabelingPublic();
const appName = whitelabeling?.appName || "Dokploy";
const appDescription =
whitelabeling?.appDescription ||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
const logoUrl =
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
<div className="absolute inset-0 bg-muted" />
<Link
href="https://dokploy.com"
href="/"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" />
Dokploy
<Logo className="size-10" logoUrl={logoUrl} />
{appName}
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg text-primary">
&ldquo;The Open Source alternative to Netlify, Vercel,
Heroku.&rdquo;
</p>
<p className="text-lg text-primary">{appDescription}</p>
</blockquote>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import {
ChevronRight,
ChevronsUpDown,
CircleHelp,
ClipboardList,
Clock,
CreditCard,
Database,
@@ -24,6 +25,7 @@ import {
LogIn,
type LucideIcon,
Package,
Palette,
PieChart,
Rocket,
Server,
@@ -91,13 +93,21 @@ import { UserNav } from "./user-nav";
// The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["user"]["get"];
type PermissionsOutput =
inferRouterOutputs<AppRouter>["user"]["getPermissions"];
type EnabledOpts = {
auth?: AuthQueryOutput;
permissions?: PermissionsOutput;
isCloud: boolean;
};
type SingleNavItem = {
isSingle?: true;
title: string;
url: string;
icon?: LucideIcon;
isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
isEnabled?: (opts: EnabledOpts) => boolean;
};
// NavItem type
@@ -111,10 +121,7 @@ type NavItem =
title: string;
icon: LucideIcon;
items: SingleNavItem[];
isEnabled?: (opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
isEnabled?: (opts: EnabledOpts) => boolean;
};
// ExternalLink type
@@ -123,7 +130,7 @@ type ExternalLink = {
name: string;
url: string;
icon: React.ComponentType<{ className?: string }>;
isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
isEnabled?: (opts: EnabledOpts) => boolean;
};
// Menu type
@@ -151,14 +158,16 @@ const MENU: Menu = {
title: "Deployments",
url: "/dashboard/deployments",
icon: Rocket,
isEnabled: ({ permissions }) => !!permissions?.deployment.read,
},
{
isSingle: true,
title: "Monitoring",
url: "/dashboard/monitoring",
icon: BarChartHorizontalBigIcon,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud }) => !isCloud,
// Only enabled in non-cloud environments and if user has monitoring.read
isEnabled: ({ isCloud, permissions }) =>
!isCloud && !!permissions?.monitoring.read,
},
{
isSingle: true,
@@ -166,64 +175,44 @@ const MENU: Menu = {
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) =>
!isCloud && (auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ isCloud, permissions }) =>
!isCloud && !!permissions?.organization.update,
},
{
isSingle: true,
title: "Traefik File System",
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToTraefikFiles) &&
!isCloud
),
// Only enabled for users with access to Traefik files in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.traefikFiles.read && !isCloud),
},
{
isSingle: true,
title: "Docker",
url: "/dashboard/docker",
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
},
{
isSingle: true,
title: "Swarm",
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
},
{
isSingle: true,
title: "Requests",
url: "/dashboard/requests",
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
// Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.docker.read && !isCloud),
},
// Legacy unused menu, adjusted to the new structure
@@ -290,8 +279,8 @@ const MENU: Menu = {
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.organization.update && !isCloud),
},
{
isSingle: true,
@@ -304,70 +293,59 @@ const MENU: Menu = {
title: "Remote Servers",
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ permissions }) => !!permissions?.server.read,
},
{
isSingle: true,
title: "Users",
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
// Only enabled for users with member.read permission
isEnabled: ({ permissions }) => !!permissions?.member.read,
},
{
isSingle: true,
title: "Audit Logs",
icon: ClipboardList,
url: "/dashboard/settings/audit-logs",
isEnabled: ({ permissions }) => !!permissions?.auditLog.read,
},
{
isSingle: true,
title: "SSH Keys",
icon: KeyRound,
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth }) =>
!!(
auth?.role === "owner" ||
auth?.canAccessToSSHKeys ||
auth?.role === "admin"
),
// Only enabled for users with access to SSH keys
isEnabled: ({ permissions }) => !!permissions?.sshKeys.read,
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
title: "Git",
url: "/dashboard/settings/git-providers",
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth }) =>
!!(
auth?.role === "owner" ||
auth?.canAccessToGitProviders ||
auth?.role === "admin"
),
// Only enabled for users with access to Git providers
isEnabled: ({ permissions }) => !!permissions?.gitProviders.read,
},
{
isSingle: true,
title: "Registry",
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ permissions }) => !!permissions?.registry.read,
},
{
isSingle: true,
title: "S3 Destinations",
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ permissions }) => !!permissions?.destination.read,
},
{
@@ -375,9 +353,7 @@ const MENU: Menu = {
title: "Certificates",
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ permissions }) => !!permissions?.certificate.read,
},
{
isSingle: true,
@@ -385,24 +361,23 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.organization.update && !isCloud),
},
{
isSingle: true,
title: "Notifications",
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
// Only enabled for users with access to notifications
isEnabled: ({ permissions }) => !!permissions?.notification.read,
},
{
isSingle: true,
title: "Billing",
url: "/dashboard/settings/billing",
icon: CreditCard,
// Only enabled for admins in cloud environments
// Only enabled for owners in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
},
{
@@ -410,7 +385,7 @@ const MENU: Menu = {
title: "License",
url: "/dashboard/settings/license",
icon: Key,
// Only enabled for admins in non-cloud environments
// Only enabled for owners
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
@@ -419,8 +394,15 @@ const MENU: Menu = {
url: "/dashboard/settings/sso",
icon: LogIn,
// Enabled for admins in both cloud and self-hosted (enterprise)
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
title: "Whitelabeling",
url: "/dashboard/settings/whitelabeling",
icon: Palette,
// Only enabled for owners in non-cloud environments (enterprise)
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
},
],
@@ -444,39 +426,45 @@ const MENU: Menu = {
*/
function createMenuForAuthUser(opts: {
auth?: AuthQueryOutput;
permissions?: PermissionsOutput;
isCloud: boolean;
whitelabeling?: {
docsUrl?: string | null;
supportUrl?: string | null;
} | null;
}): Menu {
const filterEnabled = <
T extends {
isEnabled?: (o: EnabledOpts) => boolean;
},
>(
items: readonly T[],
): T[] =>
items.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
permissions: opts.permissions,
isCloud: opts.isCloud,
}),
) as T[];
// Apply whitelabeling URL overrides to help items
const helpItems = filterEnabled(MENU.help).map((item) => {
if (opts.whitelabeling?.docsUrl && item.name === "Documentation") {
return { ...item, url: opts.whitelabeling.docsUrl };
}
if (opts.whitelabeling?.supportUrl && item.name === "Support") {
return { ...item, url: opts.whitelabeling.supportUrl };
}
return item;
});
return {
// Filter the home items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
home: MENU.home.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
isCloud: opts.isCloud,
}),
),
// Filter the settings items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
settings: MENU.settings.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
isCloud: opts.isCloud,
}),
),
// Filter the help items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
help: MENU.help.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
isCloud: opts.isCloud,
}),
),
home: filterEnabled(MENU.home),
settings: filterEnabled(MENU.settings),
help: helpItems,
};
}
@@ -546,7 +534,7 @@ function SidebarLogo() {
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession();
const { data: session } = api.user.session.useQuery();
const {
data: organizations,
refetch,
@@ -557,8 +545,8 @@ function SidebarLogo() {
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
api.organization.setDefault.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const _utils = api.useUtils();
const isCollapsed = state === "collapsed" && !isMobile;
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery();
@@ -583,9 +571,7 @@ function SidebarLogo() {
<SidebarMenu
className={cn(
"flex gap-2",
state === "collapsed"
? "flex-col"
: "flex-row justify-between items-center",
isCollapsed ? "flex-col" : "flex-row justify-between items-center",
)}
>
{/* Organization Logo and Selector */}
@@ -593,17 +579,17 @@ function SidebarLogo() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size={state === "collapsed" ? "sm" : "lg"}
size={isCollapsed ? "sm" : "lg"}
className={cn(
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
state === "collapsed" &&
isCollapsed &&
"flex justify-center items-center p-2 h-10 w-10 mx-auto",
)}
>
<div
className={cn(
"flex items-center gap-2",
state === "collapsed" && "justify-center",
isCollapsed && "justify-center",
)}
>
<div
@@ -615,7 +601,7 @@ function SidebarLogo() {
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
isCollapsed ? "size-4" : "size-5",
)}
logoUrl={activeOrganization?.logo || undefined}
/>
@@ -623,7 +609,7 @@ function SidebarLogo() {
<div
className={cn(
"flex flex-col items-start",
state === "collapsed" && "hidden",
isCollapsed && "hidden",
)}
>
<p className="text-sm font-medium leading-none">
@@ -632,7 +618,7 @@ function SidebarLogo() {
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", state === "collapsed" && "hidden")}
className={cn("ml-auto", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</DropdownMenuTrigger>
@@ -781,7 +767,7 @@ function SidebarLogo() {
</SidebarMenuItem>
{/* Notification Bell */}
<SidebarMenuItem className={cn(state === "collapsed" && "mt-2")}>
<SidebarMenuItem className={cn(isCollapsed && "mt-2")}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -789,7 +775,7 @@ function SidebarLogo() {
size="icon"
className={cn(
"relative",
state === "collapsed" && "h-8 w-8 p-1.5 mx-auto",
isCollapsed && "h-8 w-8 p-1.5 mx-auto",
)}
>
<Bell className="size-4" />
@@ -885,7 +871,12 @@ export default function Page({ children }: Props) {
const pathname = usePathname();
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: whitelabeling } = api.whitelabeling.get.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -894,7 +885,12 @@ export default function Page({ children }: Props) {
home: filteredHome,
settings: filteredSettings,
help,
} = createMenuForAuthUser({ auth, isCloud: !!isCloud });
} = createMenuForAuthUser({
auth,
permissions,
isCloud: !!isCloud,
whitelabeling,
});
const activeItem = findActiveNavItem(
[...filteredHome, ...filteredSettings],
@@ -1134,7 +1130,7 @@ export default function Page({ children }: Props) {
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
{!isCloud && permissions?.organization.update && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>
@@ -1142,15 +1138,15 @@ export default function Page({ children }: Props) {
<SidebarMenuItem>
<UserNav />
</SidebarMenuItem>
{whitelabeling?.footerText && (
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
{whitelabeling.footerText}
</div>
)}
{dokployVersion && (
<>
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
Version {dokployVersion}
</div>
<div className="hidden text-xs text-muted-foreground text-center group-data-[collapsible=icon]:block">
{dokployVersion}
</div>
</>
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
Version {dokployVersion}
</div>
)}
</SidebarMenu>
</SidebarFooter>

View File

@@ -21,6 +21,7 @@ const _AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const UserNav = () => {
const router = useRouter();
const { data } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
// const { mutateAsync } = api.auth.logout.useMutation();
@@ -94,9 +95,7 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
{(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToTraefikFiles) && (
{permissions?.traefikFiles.read && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -106,9 +105,7 @@ export const UserNav = () => {
Traefik
</DropdownMenuItem>
)}
{(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToDocker) && (
{permissions?.docker.read && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -122,7 +119,7 @@ export const UserNav = () => {
)}
</>
) : (
(data?.role === "owner" || data?.role === "admin") && (
permissions?.organization.update && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {

View File

@@ -0,0 +1,230 @@
"use client";
import type { AuditLog } from "@dokploy/server/db/schema";
import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import {
ArrowUpDown,
FileJson,
LogIn,
LogOut,
PlusCircle,
RefreshCw,
RotateCcw,
Trash2,
Upload,
XCircle,
} from "lucide-react";
import React from "react";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
const ACTION_CONFIG: Record<
string,
{ label: string; icon: React.ElementType; className: string }
> = {
create: {
label: "Created",
icon: PlusCircle,
className:
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
},
update: {
label: "Updated",
icon: RefreshCw,
className:
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
},
delete: {
label: "Deleted",
icon: Trash2,
className: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
},
deploy: {
label: "Deployed",
icon: Upload,
className:
"bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
},
cancel: {
label: "Cancelled",
icon: XCircle,
className:
"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
},
redeploy: {
label: "Redeployed",
icon: RotateCcw,
className:
"bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
},
login: {
label: "Login",
icon: LogIn,
className:
"bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/20",
},
logout: {
label: "Logout",
icon: LogOut,
className:
"bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
},
};
const RESOURCE_LABELS: Record<string, string> = {
project: "Project",
service: "Service",
environment: "Environment",
deployment: "Deployment",
user: "User",
customRole: "Custom Role",
domain: "Domain",
certificate: "Certificate",
registry: "Registry",
server: "Server",
sshKey: "SSH Key",
gitProvider: "Git Provider",
notification: "Notification",
settings: "Settings",
session: "Session",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
if (!metadata)
return <span className="text-muted-foreground text-sm"></span>;
const formatted = React.useMemo(() => {
try {
return JSON.stringify(JSON.parse(metadata), null, 2);
} catch {
return metadata;
}
}, [metadata]);
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs">
<FileJson className="h-3.5 w-3.5" />
View
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Metadata</DialogTitle>
</DialogHeader>
<CodeEditor
value={formatted}
language="json"
lineNumbers={false}
readOnly
className="min-h-[200px] max-h-[400px] overflow-auto rounded-md"
/>
</DialogContent>
</Dialog>
);
}
export const columns: ColumnDef<AuditLog>[] = [
{
accessorKey: "createdAt",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Date
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{format(new Date(row.getValue("createdAt")), "MMM d, yyyy HH:mm")}
</span>
),
},
{
accessorKey: "userEmail",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
User
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="text-sm">{row.getValue("userEmail")}</span>
),
},
{
accessorKey: "action",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Action
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const action = row.getValue("action") as string;
const config = ACTION_CONFIG[action];
if (!config) {
return <span className="text-xs text-muted-foreground">{action}</span>;
}
const Icon = config.icon;
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${config.className}`}
>
<Icon className="size-3" />
{config.label}
</span>
);
},
},
{
accessorKey: "resourceType",
header: "Resource",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{RESOURCE_LABELS[row.getValue("resourceType") as string] ??
row.getValue("resourceType")}
</span>
),
},
{
accessorKey: "resourceName",
header: "Name",
cell: ({ row }) => (
<span className="text-sm font-medium">
{(row.getValue("resourceName") as string) ?? "—"}
</span>
),
},
{
accessorKey: "userRole",
header: "Role",
cell: ({ row }) => (
<span className="text-sm text-muted-foreground capitalize">
{row.getValue("userRole")}
</span>
),
},
{
accessorKey: "metadata",
header: "Metadata",
cell: ({ row }) => <MetadataCell metadata={row.getValue("metadata")} />,
},
];

View File

@@ -0,0 +1,400 @@
"use client";
import type { AuditLog } from "@dokploy/server/db/schema";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { format } from "date-fns";
import { CalendarIcon, ChevronDown, X } from "lucide-react";
import React from "react";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const ACTION_OPTIONS = [
{ value: "create", label: "Created" },
{ value: "update", label: "Updated" },
{ value: "delete", label: "Deleted" },
{ value: "deploy", label: "Deployed" },
{ value: "cancel", label: "Cancelled" },
{ value: "redeploy", label: "Redeployed" },
{ value: "login", label: "Login" },
{ value: "logout", label: "Logout" },
];
const RESOURCE_OPTIONS = [
{ value: "project", label: "Projects" },
{ value: "service", label: "Applications / Services" },
{ value: "environment", label: "Environments" },
{ value: "deployment", label: "Deployments" },
{ value: "user", label: "Users" },
{ value: "customRole", label: "Custom Roles" },
{ value: "domain", label: "Domains" },
{ value: "certificate", label: "Certificates" },
{ value: "registry", label: "Registries" },
{ value: "server", label: "Remote Servers" },
{ value: "sshKey", label: "SSH Keys" },
{ value: "gitProvider", label: "Git Providers" },
{ value: "notification", label: "Notifications" },
{ value: "settings", label: "Settings" },
{ value: "session", label: "Sessions (Login/Logout)" },
];
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
type AuditAction =
| "create"
| "update"
| "delete"
| "deploy"
| "cancel"
| "redeploy"
| "login"
| "logout";
type AuditResourceType =
| "project"
| "service"
| "environment"
| "deployment"
| "user"
| "customRole"
| "domain"
| "certificate"
| "registry"
| "server"
| "sshKey"
| "gitProvider"
| "notification"
| "settings"
| "session";
export interface AuditLogFilters {
userEmail: string;
resourceName: string;
action: AuditAction | "";
resourceType: AuditResourceType | "";
dateRange: DateRange | undefined;
}
interface DataTableProps {
columns: ColumnDef<AuditLog>[];
data: AuditLog[];
total: number;
pageIndex: number;
pageSize: number;
filters: AuditLogFilters;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onFilterChange: <K extends keyof AuditLogFilters>(
key: K,
value: AuditLogFilters[K],
) => void;
isLoading?: boolean;
}
export function DataTable({
columns,
data,
total,
pageIndex,
pageSize,
filters,
onPageChange,
onPageSizeChange,
onFilterChange,
isLoading,
}: DataTableProps) {
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnVisibilityChange: setColumnVisibility,
manualPagination: true,
manualFiltering: true,
rowCount: total,
state: {
sorting,
columnVisibility,
},
});
const pageCount = Math.ceil(total / pageSize);
const hasFilters =
filters.userEmail ||
filters.resourceName ||
filters.action ||
filters.resourceType ||
filters.dateRange;
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2 flex-wrap">
<Input
placeholder="Filter by user..."
value={filters.userEmail}
onChange={(e) => onFilterChange("userEmail", e.target.value)}
className="max-w-xs"
/>
<Input
placeholder="Filter by name..."
value={filters.resourceName}
onChange={(e) => onFilterChange("resourceName", e.target.value)}
className="max-w-xs"
/>
<Select
value={filters.action || "__all__"}
onValueChange={(value) =>
onFilterChange(
"action",
value === "__all__" ? "" : (value as AuditAction),
)
}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All actions</SelectItem>
{ACTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.resourceType || "__all__"}
onValueChange={(value) =>
onFilterChange(
"resourceType",
value === "__all__" ? "" : (value as AuditResourceType),
)
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All resources" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All resources</SelectItem>
{RESOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 gap-1.5 text-sm font-normal"
>
<CalendarIcon className="h-4 w-4" />
{filters.dateRange?.from ? (
filters.dateRange.to ? (
`${format(filters.dateRange.from, "MMM d")} ${format(filters.dateRange.to, "MMM d, yyyy")}`
) : (
format(filters.dateRange.from, "MMM d, yyyy")
)
) : (
<span className="text-muted-foreground">Date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
selected={filters.dateRange}
onSelect={(range) => onFilterChange("dateRange", range)}
numberOfMonths={2}
initialFocus
/>
</PopoverContent>
</Popover>
{hasFilters && (
<Button
variant="ghost"
size="sm"
onClick={() => {
onFilterChange("userEmail", "");
onFilterChange("resourceName", "");
onFilterChange("action", "");
onFilterChange("resourceType", "");
onFilterChange("dateRange", undefined);
}}
className="text-muted-foreground"
>
<X className="h-4 w-4 mr-1" />
Clear
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
className="capitalize"
checked={col.getIsVisible()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
>
{col.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No audit logs found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} {total === 1 ? "entry" : "entries"} total
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-sm whitespace-nowrap">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="w-[80px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="whitespace-nowrap">
Page {pageIndex + 1} of {Math.max(1, pageCount)}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pageIndex - 1)}
disabled={pageIndex === 0}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pageIndex + 1)}
disabled={pageIndex + 1 >= pageCount}
>
Next
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { ClipboardList } from "lucide-react";
import React from "react";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { columns } from "./columns";
import { type AuditLogFilters, DataTable } from "./data-table";
function AuditLogsContent() {
const [pageIndex, setPageIndex] = React.useState(0);
const [pageSize, setPageSize] = React.useState(50);
const [filters, setFilters] = React.useState<AuditLogFilters>({
userEmail: "",
resourceName: "",
action: "",
resourceType: "",
dateRange: undefined,
});
const [debouncedText, setDebouncedText] = React.useState({
userEmail: "",
resourceName: "",
});
React.useEffect(() => {
const t = setTimeout(() => {
setDebouncedText({
userEmail: filters.userEmail,
resourceName: filters.resourceName,
});
setPageIndex(0);
}, 400);
return () => clearTimeout(t);
}, [filters.userEmail, filters.resourceName]);
const handleFilterChange = <K extends keyof AuditLogFilters>(
key: K,
value: AuditLogFilters[K],
) => {
setFilters((prev) => ({ ...prev, [key]: value }));
if (key !== "userEmail" && key !== "resourceName") {
setPageIndex(0);
}
};
const handlePageSizeChange = (size: number) => {
setPageSize(size);
setPageIndex(0);
};
const { data, isLoading } = api.auditLog.all.useQuery({
userEmail: debouncedText.userEmail || undefined,
resourceName: debouncedText.resourceName || undefined,
action: filters.action || undefined,
resourceType: filters.resourceType || undefined,
from: filters.dateRange?.from,
to: filters.dateRange?.to,
limit: pageSize,
offset: pageIndex * pageSize,
});
return (
<DataTable
columns={columns}
data={data?.logs ?? []}
total={data?.total ?? 0}
pageIndex={pageIndex}
pageSize={pageSize}
filters={filters}
onPageChange={setPageIndex}
onPageSizeChange={handlePageSizeChange}
onFilterChange={handleFilterChange}
isLoading={isLoading}
/>
);
}
export function ShowAuditLogs() {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl w-full mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<EnterpriseFeatureGate
lockedProps={{
title: "Audit Logs",
description:
"Get full visibility into every action performed across your organization. Audit logs are available as part of Dokploy Enterprise.",
ctaLabel: "Manage License",
}}
>
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<ClipboardList className="h-5 w-5 text-muted-foreground self-center" />
Audit Logs
</CardTitle>
<CardDescription>
Track all actions performed by members in your organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<AuditLogsContent />
</CardContent>
</EnterpriseFeatureGate>
</div>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface WhitelabelingPreviewProps {
config: {
appName?: string;
logoUrl?: string;
footerText?: string;
};
}
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
const appName = config.appName || "Dokploy";
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Live Preview</CardTitle>
<CardDescription>
A quick preview of how your branding changes will look.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden">
{/* Simulated sidebar header */}
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
{config.logoUrl ? (
<img
src={config.logoUrl}
alt="Preview Logo"
className="size-8 rounded-sm object-contain"
/>
) : (
<div className="size-8 rounded-sm flex items-center justify-center bg-primary text-primary-foreground font-bold text-sm">
{appName.charAt(0).toUpperCase()}
</div>
)}
<span className="font-semibold text-sm">{appName}</span>
</div>
{/* Simulated content area */}
<div className="p-4 bg-background">
<div className="flex items-center gap-2 mb-3">
<div className="h-2 w-16 rounded-full bg-primary" />
<div className="h-2 w-24 rounded-full bg-muted" />
</div>
<div className="flex gap-2">
<div className="px-3 py-1.5 rounded-md text-xs bg-primary text-primary-foreground font-medium">
Button
</div>
<div className="px-3 py-1.5 rounded-md text-xs border font-medium">
Secondary
</div>
</div>
</div>
{/* Simulated footer */}
{config.footerText && (
<div className="px-4 py-2 border-t text-xs text-muted-foreground text-center bg-sidebar">
{config.footerText}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import Head from "next/head";
import { api } from "@/utils/api";
export function WhitelabelingProvider() {
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
if (!config) return null;
return (
<>
<Head>
{config.metaTitle && <title>{config.metaTitle}</title>}
{config.faviconUrl && <link rel="icon" href={config.faviconUrl} />}
</Head>
{config.customCss && (
<style
id="whitelabeling-styles"
dangerouslySetInnerHTML={{
__html: config.customCss,
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,589 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Loader2, RotateCcw } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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";
import { WhitelabelingPreview } from "./whitelabeling-preview";
const safeUrlField = z
.string()
.refine((val) => val === "" || /^https?:\/\//i.test(val), {
message: "Only http:// and https:// URLs are allowed",
});
const formSchema = z.object({
appName: z.string(),
appDescription: z.string(),
logoUrl: safeUrlField,
faviconUrl: safeUrlField,
customCss: z.string(),
loginLogoUrl: safeUrlField,
supportUrl: safeUrlField,
docsUrl: safeUrlField,
errorPageTitle: z.string(),
errorPageDescription: z.string(),
metaTitle: z.string(),
footerText: z.string(),
});
type FormSchema = z.infer<typeof formSchema>;
const DEFAULT_CSS_TEMPLATE = `/* ============================================
Dokploy Default Theme - CSS Variables
Modify these values to customize your instance.
============================================ */
/* ---------- Light Mode ---------- */
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
/* Sidebar */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Charts */
--chart-1: 173 58% 39%;
--chart-2: 12 76% 61%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
/* ---------- Dark Mode ---------- */
.dark {
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: 240 4% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 4% 10%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 4% 10%;
--ring: 240 4.9% 83.9%;
/* Sidebar */
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Charts */
--chart-1: 220 70% 50%;
--chart-2: 340 75% 55%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 160 60% 45%;
}
/* ---------- Custom Styles ---------- */
/* Add your own CSS rules below */
`;
export function WhitelabelingSettings() {
const utils = api.useUtils();
const {
data,
isPending: isLoading,
refetch,
} = api.whitelabeling.get.useQuery();
const { mutateAsync: updateWhitelabeling, isPending: isUpdating } =
api.whitelabeling.update.useMutation();
const { mutateAsync: resetWhitelabeling, isPending: isResetting } =
api.whitelabeling.reset.useMutation();
const form = useForm<FormSchema>({
defaultValues: {
appName: "",
appDescription: "",
logoUrl: "",
faviconUrl: "",
customCss: "",
loginLogoUrl: "",
supportUrl: "",
docsUrl: "",
errorPageTitle: "",
errorPageDescription: "",
metaTitle: "",
footerText: "",
},
resolver: zodResolver(formSchema),
});
useEffect(() => {
if (data) {
form.reset({
appName: data.appName ?? "",
appDescription: data.appDescription ?? "",
logoUrl: data.logoUrl ?? "",
faviconUrl: data.faviconUrl ?? "",
customCss: data.customCss ?? "",
loginLogoUrl: data.loginLogoUrl ?? "",
supportUrl: data.supportUrl ?? "",
docsUrl: data.docsUrl ?? "",
errorPageTitle: data.errorPageTitle ?? "",
errorPageDescription: data.errorPageDescription ?? "",
metaTitle: data.metaTitle ?? "",
footerText: data.footerText ?? "",
});
}
}, [data, form]);
if (isLoading) {
return (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Loading whitelabeling settings...
</span>
</div>
);
}
const onSubmit = async (values: FormSchema) => {
await updateWhitelabeling({
whitelabelingConfig: {
appName: values.appName || null,
appDescription: values.appDescription || null,
logoUrl: values.logoUrl || null,
faviconUrl: values.faviconUrl || null,
customCss: values.customCss || null,
loginLogoUrl: values.loginLogoUrl || null,
supportUrl: values.supportUrl || null,
docsUrl: values.docsUrl || null,
errorPageTitle: values.errorPageTitle || null,
errorPageDescription: values.errorPageDescription || null,
metaTitle: values.metaTitle || null,
footerText: values.footerText || null,
},
})
.then(async () => {
toast.success("Whitelabeling settings updated");
await refetch();
await utils.whitelabeling.getPublic.invalidate();
await utils.whitelabeling.get.invalidate();
})
.catch((error) => {
toast.error(
error?.message || "Failed to update whitelabeling settings",
);
});
};
const handleReset = async () => {
await resetWhitelabeling()
.then(async () => {
toast.success("Whitelabeling settings reset to defaults");
await refetch();
await utils.whitelabeling.getPublic.invalidate();
await utils.whitelabeling.get.invalidate();
})
.catch((error) => {
toast.error(error?.message || "Failed to reset whitelabeling settings");
});
};
return (
<div className="flex flex-col gap-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
{/* Branding Section */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Branding</CardTitle>
<CardDescription>
Customize the application name, logos, and favicon to match your
brand identity.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>Application Name</FormLabel>
<FormControl>
<Input placeholder="Dokploy" {...field} />
</FormControl>
<FormDescription>
Replaces "Dokploy" across the entire interface.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Application Description</FormLabel>
<FormControl>
<Input
placeholder="The Open Source alternative to Netlify, Vercel, Heroku."
{...field}
/>
</FormControl>
<FormDescription>
Tagline shown on the login/onboarding pages. Defaults to
the standard Dokploy description if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="logoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.svg"
{...field}
/>
</FormControl>
<FormDescription>
Main logo shown in the sidebar and header. Recommended
size: 128x128px.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="loginLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login Page Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo.svg"
{...field}
/>
</FormControl>
<FormDescription>
Logo displayed on the login page. If empty, the main logo
is used.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="faviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon.ico"
{...field}
/>
</FormControl>
<FormDescription>
Browser tab icon. Supports .ico, .png, and .svg formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Appearance Section */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Appearance</CardTitle>
<CardDescription>
Customize the look and feel of the application with custom CSS.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="customCss"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Custom CSS</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
form.setValue("customCss", DEFAULT_CSS_TEMPLATE);
}}
>
Load Default Styles
</Button>
</div>
<FormControl>
<div className="max-h-[350px] overflow-auto">
<CodeEditor
language="css"
value={field.value}
onChange={field.onChange}
placeholder="/* Click 'Load Default Styles' to start with the base theme variables */"
lineWrapping
/>
</div>
</FormControl>
<FormDescription>
Inject custom CSS styles globally. Click "Load Default
Styles" to get the base theme CSS variables as a starting
point.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Metadata & Links Section */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Metadata & Links</CardTitle>
<CardDescription>
Customize the page title, footer text, and sidebar links.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="metaTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Page Title</FormLabel>
<FormControl>
<Input placeholder="Dokploy" {...field} />
</FormControl>
<FormDescription>
Browser tab title. Defaults to "Dokploy" if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="footerText"
render={({ field }) => (
<FormItem>
<FormLabel>Footer Text</FormLabel>
<FormControl>
<Input placeholder="Powered by Your Company" {...field} />
</FormControl>
<FormDescription>
Custom text displayed in the footer area.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="supportUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Support URL</FormLabel>
<FormControl>
<Input
placeholder="https://support.example.com"
{...field}
/>
</FormControl>
<FormDescription>
Custom URL for the "Support" link in the sidebar.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="docsUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Documentation URL</FormLabel>
<FormControl>
<Input
placeholder="https://docs.example.com"
{...field}
/>
</FormControl>
<FormDescription>
Custom URL for the "Documentation" link in the sidebar.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Error Pages Section */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Error Pages</CardTitle>
<CardDescription>
Customize the error page messages shown to users.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="errorPageTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Error Page Title</FormLabel>
<FormControl>
<Input placeholder="Something went wrong" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="errorPageDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Error Page Description</FormLabel>
<FormControl>
<Textarea
placeholder="We're sorry, but an unexpected error occurred. Please try again later."
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Actions */}
<div className="flex items-center justify-between">
<DialogAction
title="Reset Whitelabeling"
description="Are you sure you want to reset all whitelabeling settings to their defaults? This action cannot be undone."
type="destructive"
onClick={handleReset}
>
<Button variant="outline" type="button" isLoading={isResetting}>
<RotateCcw className="size-4 mr-2" />
Reset to Defaults
</Button>
</DialogAction>
<Button type="submit" isLoading={isUpdating} disabled={isUpdating}>
Save Changes
</Button>
</div>
</form>
</Form>
{/* Live Preview */}
<WhitelabelingPreview config={form.watch()} />
</div>
);
}

View File

@@ -17,6 +17,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
interface BreadcrumbEntry {
name: string;
@@ -32,9 +34,11 @@ interface Props {
}
export const BreadcrumbSidebar = ({ list }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center justify-between w-full">
<div className="flex items-center justify-between w-full px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -75,6 +79,7 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
</BreadcrumbList>
</Breadcrumb>
</div>
{!isCloud && <TimeBadge />}
</div>
</header>
);

View File

@@ -4,12 +4,14 @@ import {
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { EditorView } from "@codemirror/view";
import { search, searchKeymap } from "@codemirror/search";
import { EditorView, keymap } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
@@ -130,7 +132,7 @@ function dockerComposeComplete(
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties" | "shell";
language?: "yaml" | "json" | "properties" | "shell" | "css";
lineWrapping?: boolean;
lineNumbers?: boolean;
}
@@ -155,13 +157,17 @@ export const CodeEditor = ({
}}
theme={resolvedTheme === "dark" ? githubDark : githubLight}
extensions={[
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
: language === "json"
? json()
: language === "shell"
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
: language === "css"
? css()
: language === "shell"
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [],
language === "yaml"
? autocompletion({

View File

@@ -213,7 +213,9 @@ const Sidebar = React.forwardRef<
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
<div className="flex h-full w-full flex-col overflow-hidden">
{children}
</div>
</SheetContent>
</Sheet>
);
@@ -412,7 +414,7 @@ const SidebarContent = React.forwardRef<
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-y-auto",
className,
)}
{...props}

View File

@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelingConfig" jsonb DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"metaTitle":null,"footerText":null}'::jsonb;

View File

@@ -0,0 +1,31 @@
CREATE TABLE "organization_role" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"role" text NOT NULL,
"permission" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "audit_log" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text,
"user_id" text,
"user_email" text NOT NULL,
"user_role" text NOT NULL,
"action" text NOT NULL,
"resource_type" text NOT NULL,
"resource_id" text,
"resource_name" text,
"metadata" text,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "organization_role" ADD CONSTRAINT "organization_role_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "organizationRole_organizationId_idx" ON "organization_role" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "organizationRole_role_idx" ON "organization_role" USING btree ("role");--> statement-breakpoint
CREATE INDEX "auditLog_organizationId_idx" ON "audit_log" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "auditLog_userId_idx" ON "audit_log" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "auditLog_createdAt_idx" ON "audit_log" USING btree ("created_at");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1037,6 +1037,20 @@
"when": 1771830695385,
"tag": "0147_right_lake",
"breakpoints": true
},
{
"idx": 148,
"version": "7",
"when": 1773129798212,
"tag": "0148_futuristic_bullseye",
"breakpoints": true
},
{
"idx": 149,
"version": "7",
"when": 1773637297592,
"tag": "0149_rare_radioactive_man",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,7 @@
import { ssoClient } from "@better-auth/sso/client";
import { apiKeyClient } from "@better-auth/api-key/client";
import {
adminClient,
apiKeyClient,
inferAdditionalFields,
organizationClient,
twoFactorClient,

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.28.3",
"version": "v0.28.7",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -39,8 +39,6 @@
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"resend": "^6.0.2",
"@better-auth/sso": "1.5.0-beta.16",
"@ai-sdk/anthropic": "^3.0.44",
"@ai-sdk/azure": "^3.0.30",
"@ai-sdk/cohere": "^3.0.21",
@@ -48,12 +46,16 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/view": "6.29.0",
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.17",
"@faker-js/faker": "^8.4.1",
@@ -98,11 +100,10 @@
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.5.0-beta.16",
"better-auth": "1.5.4",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.67.3",
"shell-quote": "^1.8.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
@@ -139,6 +140,9 @@
"react-hook-form": "^7.71.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "1.15.0",
@@ -154,12 +158,9 @@
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^4.3.6",
"zod-form-data": "^3.0.1",
"semver": "7.7.3"
"zod-form-data": "^3.0.1"
},
"devDependencies": {
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6",
@@ -171,6 +172,8 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/swagger-ui-react": "^4.19.0",
"@types/ws": "8.5.10",

View File

@@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
import { Toaster } from "@/components/ui/sonner";
import { api } from "@/utils/api";
@@ -48,6 +49,7 @@ const MyApp = ({
forcedTheme={Component.theme}
>
<NextTopLoader color="hsl(var(--sidebar-ring))" />
<WhitelabelingProvider />
<Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)}

View File

@@ -2,6 +2,7 @@ import type { NextPageContext } from "next";
import Link from "next/link";
import { Logo } from "@/components/shared/logo";
import { buttonVariants } from "@/components/ui/button";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
interface Props {
statusCode: number;
@@ -10,18 +11,20 @@ interface Props {
export default function Custom404({ statusCode, error }: Props) {
const displayStatusCode = statusCode || 400;
const { config: whitelabeling } = useWhitelabelingPublic();
const appName = whitelabeling?.appName || "Dokploy";
const logoUrl = whitelabeling?.logoUrl || undefined;
const errorTitle = whitelabeling?.errorPageTitle;
const errorDescription = whitelabeling?.errorPageDescription;
return (
<div className="h-screen">
<div className="max-w-[50rem] flex flex-col mx-auto size-full">
<header className="mb-auto flex justify-center z-50 w-full py-4">
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
<Link
href="https://dokploy.com"
target="_blank"
className="flex flex-row items-center gap-2"
>
<Logo />
<span className="font-medium text-sm">Dokploy</span>
<Link href="/" className="flex flex-row items-center gap-2">
<Logo logoUrl={logoUrl} />
<span className="font-medium text-sm">{appName}</span>
</Link>
</nav>
</header>
@@ -30,19 +33,18 @@ export default function Custom404({ statusCode, error }: Props) {
<h1 className="block text-7xl font-bold text-primary sm:text-9xl">
{displayStatusCode}
</h1>
{/* <AlertBlock className="max-w-xs mx-auto">
<p className="text-muted-foreground">
Oops, something went wrong.
</p>
<p className="text-muted-foreground">
Sorry, we couldn't find your page.
</p>
</AlertBlock> */}
<p className="mt-3 text-muted-foreground">
{statusCode === 404
? "Sorry, we couldn't find your page."
: "Oops, something went wrong."}
{errorTitle
? errorTitle
: statusCode === 404
? "Sorry, we couldn't find your page."
: "Oops, something went wrong."}
</p>
{errorDescription && (
<p className="mt-2 text-muted-foreground text-sm">
{errorDescription}
</p>
)}
{error && (
<div className="mt-3 text-red-500">
<p>{error.message}</p>
@@ -80,13 +82,17 @@ export default function Custom404({ statusCode, error }: Props) {
<footer className="mt-auto text-center py-5">
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-sm text-gray-500">
<Link
href="https://github.com/Dokploy/dokploy/issues"
target="_blank"
className="underline hover:text-primary transition-colors"
>
Submit Log in issue on Github
</Link>
{whitelabeling?.footerText ? (
whitelabeling.footerText
) : (
<Link
href="https://github.com/Dokploy/dokploy/issues"
target="_blank"
className="underline hover:text-primary transition-colors"
>
Submit Log in issue on Github
</Link>
)}
</p>
</div>
</footer>

View File

@@ -358,7 +358,8 @@ export default async function handler(
const shouldCreateDeployment =
action === "opened" ||
action === "synchronize" ||
action === "reopened";
action === "reopened" ||
action === "labeled";
const repository = githubBody?.repository?.name;
const deploymentHash = githubBody?.pull_request?.head?.sha;

View File

@@ -10,22 +10,29 @@ type Query = {
state: string;
installation_id: string;
setup_action: string;
userId: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { code, state, installation_id, userId }: Query = req.query as Query;
const { code, state, installation_id }: Query = req.query as Query;
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
}
const [action, value] = state?.split(":");
// Value could be the organizationId or the githubProviderId
const [action, ...rest] = state?.split(":");
// For gh_init: rest[0] = organizationId, rest[1] = userId
// For gh_setup: rest[0] = githubProviderId
if (action === "gh_init") {
const organizationId = rest[0];
const userId = rest[1] || (req.query.userId as string);
if (!userId) {
return res.status(400).json({ error: "Missing userId parameter" });
}
const octokit = new Octokit({});
const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions",
@@ -44,7 +51,7 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
},
value as string,
organizationId as string,
userId,
);
} else if (action === "gh_setup") {
@@ -53,7 +60,7 @@ export default async function handler(
.set({
githubInstallationId: installation_id,
})
.where(eq(github.githubId, value as string))
.where(eq(github.githubId, rest[0] as string))
.returning();
}

View File

@@ -1,4 +1,5 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { hasPermission } from "@dokploy/server/services/permission";
import { Rocket } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
@@ -79,7 +80,7 @@ DeploymentsPage.getLayout = (page: ReactElement) => {
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { user } = await validateRequest(ctx.req);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -88,6 +89,24 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
},
};
}
const canView = await hasPermission(
{
user: { id: user.id },
session: { activeOrganizationId: session?.activeOrganizationId || "" },
},
{ deployment: ["read"] },
);
if (!canView) {
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
},
};
}
return {
props: {},
};

View File

@@ -53,19 +53,15 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userR?.canAccessToDocker) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (!userPermissions?.docker.read) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {

View File

@@ -1,5 +1,6 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { hasPermission } from "@dokploy/server/services/permission";
import { Loader2 } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -99,7 +100,7 @@ export async function getServerSideProps(
},
};
}
const { user } = await validateRequest(ctx.req);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
@@ -109,6 +110,23 @@ export async function getServerSideProps(
};
}
const canView = await hasPermission(
{
user: { id: user.id },
session: { activeOrganizationId: session?.activeOrganizationId || "" },
},
{ monitoring: ["read"] },
);
if (!canView) {
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
},
};
}
return {
props: {},
};

View File

@@ -98,6 +98,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
export type Services = {
serverId?: string | null;
@@ -271,6 +272,7 @@ const EnvironmentPage = (
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId, environmentId } = props;
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: projectId,
@@ -370,6 +372,8 @@ const EnvironmentPage = (
{ projectId: selectedTargetProject },
{ enabled: !!selectedTargetProject },
);
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const emptyServices =
!currentEnvironment ||
@@ -777,7 +781,7 @@ const EnvironmentPage = (
}
if (success > 0) {
toast.success(
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
`${success} service${success !== 1 ? "s" : ""} queued for deployment`,
);
}
if (failed > 0) {
@@ -871,7 +875,8 @@ const EnvironmentPage = (
/>
<Head>
<title>
Environment: {currentEnvironment.name} | {projectData?.name} | Dokploy
Environment: {currentEnvironment.name} | {projectData?.name} |{" "}
{appName}
</title>
</Head>
<div className="w-full">
@@ -901,9 +906,7 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
</ProjectEnvironment>
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateServices) && (
{permissions?.service.create && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
@@ -1025,9 +1028,7 @@ const EnvironmentPage = (
Stop
</Button>
</DialogAction>
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.delete && (
<>
<DialogAction
title="Delete Services"
@@ -1620,6 +1621,7 @@ export async function getServerSideProps(
environmentId: params.environmentId,
});
} catch (error) {
console.log(error);
// If user doesn't have access to requested environment, redirect to accessible one
const accessibleEnvironments =
await helpers.environment.byProjectId.fetch({
@@ -1639,11 +1641,11 @@ export async function getServerSideProps(
},
};
}
// No accessible environments, redirect to home
// No accessible environments, redirect to projects
return {
redirect: {
permanent: false,
destination: "/",
destination: "/dashboard/projects",
},
};
}
@@ -1659,7 +1661,8 @@ export async function getServerSideProps(
environmentId: params.environmentId,
},
};
} catch {
} catch (error) {
console.log(error);
return {
redirect: {
permanent: false,

View File

@@ -56,6 +56,7 @@ import {
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type TabState =
| "projects"
@@ -91,10 +92,13 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.project?.projectId || "",
});
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
@@ -122,7 +126,8 @@ const Service = (
/>
<Head>
<title>
Application: {data?.name} - {data?.environment.project.name} | Dokploy
Application: {data?.name} - {data?.environment.project.name} |{" "}
{appName}
</title>
</Head>
<div className="w-full">
@@ -193,10 +198,10 @@ const Service = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.create && (
<UpdateApplication applicationId={applicationId} />
)}
{permissions?.service.delete && (
<DeleteService id={applicationId} type="application" />
)}
</div>
@@ -238,24 +243,47 @@ const Service = (
<div className="flex flex-row items-center justify-between w-full overflow-auto">
<TabsList className="flex gap-8 max-md:gap-4 justify-start">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="preview-deployments">
Preview Deployments
</TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger>
<TabsTrigger value="volume-backups">
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{permissions?.envVars.read && (
<TabsTrigger value="environment">
Environment
</TabsTrigger>
)}
{permissions?.domain.read && (
<TabsTrigger value="domains">Domains</TabsTrigger>
)}
{permissions?.deployment.read && (
<TabsTrigger value="deployments">
Deployments
</TabsTrigger>
)}
{permissions?.deployment.read && (
<TabsTrigger value="preview-deployments">
Preview Deployments
</TabsTrigger>
)}
{permissions?.schedule.read && (
<TabsTrigger value="schedules">Schedules</TabsTrigger>
)}
{permissions?.volumeBackup.read && (
<TabsTrigger value="volume-backups">
Volume Backups
</TabsTrigger>
)}
{permissions?.logs.read && (
<TabsTrigger value="logs">Logs</TabsTrigger>
)}
{data?.sourceType !== "docker" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{permissions?.monitoring.read &&
((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
</div>
@@ -264,26 +292,29 @@ const Service = (
<ShowGeneralApplication applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment applicationId={applicationId} />
</div>
</TabsContent>
{permissions?.envVars.read && (
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment applicationId={applicationId} />
</div>
</TabsContent>
)}
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures &&
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures &&
isCloud &&
data?.serverId && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
@@ -297,7 +328,7 @@ const Service = (
</div>
)} */}
{/* {toggleMonitoring ? (
{/* {toggleMonitoring ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
@@ -306,84 +337,102 @@ const Service = (
}
/>
) : ( */}
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
{/* )} */}
</>
)}
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
{/* )} */}
</>
)}
</div>
</div>
</div>
</TabsContent>
</TabsContent>
)}
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
appName={data?.appName || ""}
serverId={data?.serverId || ""}
/>
</div>
</TabsContent>
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules
id={applicationId}
scheduleType="application"
/>
</div>
</TabsContent>
<TabsContent value="deployments" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowDeployments
id={applicationId}
type="application"
serverId={data?.serverId || ""}
refreshToken={data?.refreshToken || ""}
/>
</div>
</TabsContent>
<TabsContent value="volume-backups" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowVolumeBackups
id={applicationId}
type="application"
serverId={data?.serverId || ""}
/>
</div>
</TabsContent>
<TabsContent value="preview-deployments" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPreviewDeployments applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="domains" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains id={applicationId} type="application" />
</div>
</TabsContent>
{permissions?.logs.read && (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
appName={data?.appName || ""}
serverId={data?.serverId || ""}
/>
</div>
</TabsContent>
)}
{permissions?.schedule.read && (
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules
id={applicationId}
scheduleType="application"
/>
</div>
</TabsContent>
)}
{permissions?.deployment.read && (
<TabsContent value="deployments" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowDeployments
id={applicationId}
type="application"
serverId={data?.serverId || ""}
refreshToken={data?.refreshToken || ""}
/>
</div>
</TabsContent>
)}
{permissions?.volumeBackup.read && (
<TabsContent
value="volume-backups"
className="w-full pt-2.5"
>
<div className="flex flex-col gap-4 border rounded-lg">
<ShowVolumeBackups
id={applicationId}
type="application"
serverId={data?.serverId || ""}
/>
</div>
</TabsContent>
)}
{permissions?.deployment.read && (
<TabsContent value="preview-deployments" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPreviewDeployments applicationId={applicationId} />
</div>
</TabsContent>
)}
{permissions?.domain.read && (
<TabsContent value="domains" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains id={applicationId} type="application" />
</div>
</TabsContent>
)}
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowPatches id={applicationId} type="application" />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommand applicationId={applicationId} />
<ShowClusterSettings
id={applicationId}
type="application"
/>
<ShowBuildServer applicationId={applicationId} />
<ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" />
<ShowRedirects applicationId={applicationId} />
<ShowSecurity applicationId={applicationId} />
<ShowPorts applicationId={applicationId} />
<ShowTraefikConfig applicationId={applicationId} />
</div>
</TabsContent>
{permissions?.service.create && (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommand applicationId={applicationId} />
<ShowClusterSettings
id={applicationId}
type="application"
/>
<ShowBuildServer applicationId={applicationId} />
<ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" />
<ShowRedirects applicationId={applicationId} />
<ShowSecurity applicationId={applicationId} />
<ShowPorts applicationId={applicationId} />
<ShowTraefikConfig applicationId={applicationId} />
</div>
</TabsContent>
)}
</Tabs>
)}
</CardContent>

View File

@@ -52,6 +52,7 @@ import {
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type TabState =
| "projects"
@@ -80,10 +81,13 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
@@ -111,7 +115,7 @@ const Service = (
/>
<Head>
<title>
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
Compose: {data?.name} - {data?.environment?.project?.name} | {appName}
</title>
</Head>
<div className="w-full">
@@ -182,11 +186,11 @@ const Service = (
)}
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
{permissions?.service.create && (
<UpdateCompose composeId={composeId} />
)}
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.delete && (
<DeleteService id={composeId} type="compose" />
)}
</div>
@@ -229,22 +233,45 @@ const Service = (
<div className="flex flex-row items-center w-full overflow-auto">
<TabsList className="flex gap-8 max-md:gap-4 justify-start">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger>
<TabsTrigger value="volumeBackups">
Volume Backups
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{permissions?.envVars.read && (
<TabsTrigger value="environment">
Environment
</TabsTrigger>
)}
{permissions?.domain.read && (
<TabsTrigger value="domains">Domains</TabsTrigger>
)}
{permissions?.deployment.read && (
<TabsTrigger value="deployments">
Deployments
</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="backups">Backups</TabsTrigger>
)}
{permissions?.schedule.read && (
<TabsTrigger value="schedules">Schedules</TabsTrigger>
)}
{permissions?.volumeBackup.read && (
<TabsTrigger value="volumeBackups">
Volume Backups
</TabsTrigger>
)}
{permissions?.logs.read && (
<TabsTrigger value="logs">Logs</TabsTrigger>
)}
{data?.sourceType !== "raw" && (
<TabsTrigger value="patches">Patches</TabsTrigger>
)}
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{permissions?.monitoring.read &&
((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
</div>
@@ -253,47 +280,56 @@ const Service = (
<ShowGeneralCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={composeId} type="compose" />
</div>
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={composeId} backupType="compose" />
</div>
</TabsContent>
{permissions?.envVars.read && (
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={composeId} type="compose" />
</div>
</TabsContent>
)}
{permissions?.service.create && (
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={composeId} backupType="compose" />
</div>
</TabsContent>
)}
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules id={composeId} scheduleType="compose" />
</div>
</TabsContent>
<TabsContent value="volumeBackups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowVolumeBackups
id={composeId}
type="compose"
serverId={data?.serverId || ""}
/>
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col border rounded-lg ">
{data?.serverId && isCloud ? (
<ComposePaidMonitoring
serverId={data?.serverId || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
appName={data?.appName || ""}
token={
data?.server?.metricsConfig?.server?.token || ""
}
appType={data?.composeType || "docker-compose"}
/>
) : (
<>
{/* {monitoring?.enabledFeatures &&
{permissions?.schedule.read && (
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules id={composeId} scheduleType="compose" />
</div>
</TabsContent>
)}
{permissions?.volumeBackup.read && (
<TabsContent value="volumeBackups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowVolumeBackups
id={composeId}
type="compose"
serverId={data?.serverId || ""}
/>
</div>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col border rounded-lg ">
{data?.serverId && isCloud ? (
<ComposePaidMonitoring
serverId={data?.serverId || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
appName={data?.appName || ""}
token={
data?.server?.metricsConfig?.server?.token || ""
}
appType={data?.composeType || "docker-compose"}
/>
) : (
<>
{/* {monitoring?.enabledFeatures &&
isCloud &&
data?.serverId && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2 m-4">
@@ -317,53 +353,60 @@ const Service = (
appType={data?.composeType || "docker-compose"}
/>
) : ( */}
{/* <div> */}
<ComposeFreeMonitoring
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
/>
{/* </div> */}
{/* )} */}
</>
{/* <div> */}
<ComposeFreeMonitoring
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
/>
{/* </div> */}
{/* )} */}
</>
)}
</div>
</div>
</TabsContent>
)}
{permissions?.logs.read && (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
{data?.composeType === "docker-compose" ? (
<ShowDockerLogsCompose
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
/>
) : (
<ShowDockerLogsStack
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
)}
</div>
</div>
</TabsContent>
</TabsContent>
)}
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
{data?.composeType === "docker-compose" ? (
<ShowDockerLogsCompose
{permissions?.deployment.read && (
<TabsContent value="deployments" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowDeployments
id={composeId}
type="compose"
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
refreshToken={data?.refreshToken || ""}
/>
) : (
<ShowDockerLogsStack
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
)}
</div>
</TabsContent>
</div>
</TabsContent>
)}
<TabsContent value="deployments" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowDeployments
id={composeId}
type="compose"
serverId={data?.serverId || ""}
refreshToken={data?.refreshToken || ""}
/>
</div>
</TabsContent>
<TabsContent value="domains">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains id={composeId} type="compose" />
</div>
</TabsContent>
{permissions?.domain.read && (
<TabsContent value="domains">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains id={composeId} type="compose" />
</div>
</TabsContent>
)}
<TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
@@ -371,14 +414,16 @@ const Service = (
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
</div>
</TabsContent>
{permissions?.service.create && (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
</div>
</TabsContent>
)}
</Tabs>
)}
</CardContent>

View File

@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -59,12 +60,15 @@ const Mariadb = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
@@ -94,7 +98,7 @@ const Mariadb = (
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
{appName}
</title>
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
@@ -156,10 +160,10 @@ const Mariadb = (
)}
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} />
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.create && (
<UpdateMariadb mariadbId={mariadbId} />
)}
{permissions?.service.delete && (
<DeleteService id={mariadbId} type="mariadb" />
)}
</div>
@@ -211,13 +215,24 @@ const Mariadb = (
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{permissions?.envVars.read && (
<TabsTrigger value="environment">
Environment
</TabsTrigger>
)}
{permissions?.logs.read && (
<TabsTrigger value="logs">Logs</TabsTrigger>
)}
{permissions?.monitoring.read &&
((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList>
</div>
@@ -228,25 +243,28 @@ const Mariadb = (
<ShowExternalMariadbCredentials mariadbId={mariadbId} />
</div>
</TabsContent>
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={mariadbId} type="mariadb" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures && (
{permissions?.envVars.read && (
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={mariadbId} type="mariadb" />
</div>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">
Change Monitoring
@@ -268,37 +286,42 @@ const Mariadb = (
/>
) : (
<div> */}
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
{/* </div> */}
{/* )} */}
</>
)}
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
{/* </div> */}
{/* )} */}
</>
)}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
</TabsContent>
)}
{permissions?.logs.read && (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
)}
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mariadbId} databaseType="mariadb" />
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={mariadbId}
type="mariadb"
/>
</div>
</TabsContent>
{permissions?.service.create && (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={mariadbId}
type="mariadb"
/>
</div>
</TabsContent>
)}
</Tabs>
)}
</CardContent>

View File

@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -59,11 +60,14 @@ const Mongo = (
const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
@@ -91,7 +95,8 @@ const Mongo = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{appName}
</title>
</Head>
<div className="w-full">
@@ -155,10 +160,10 @@ const Mongo = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.create && (
<UpdateMongo mongoId={mongoId} />
)}
{permissions?.service.delete && (
<DeleteService id={mongoId} type="mongo" />
)}
</div>
@@ -210,13 +215,24 @@ const Mongo = (
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{permissions?.envVars.read && (
<TabsTrigger value="environment">
Environment
</TabsTrigger>
)}
{permissions?.logs.read && (
<TabsTrigger value="logs">Logs</TabsTrigger>
)}
{permissions?.monitoring.read &&
((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList>
</div>
@@ -227,25 +243,28 @@ const Mongo = (
<ShowExternalMongoCredentials mongoId={mongoId} />
</div>
</TabsContent>
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={mongoId} type="mongo" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures && (
{permissions?.envVars.read && (
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={mongoId} type="mongo" />
</div>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">
Change Monitoring
@@ -267,24 +286,27 @@ const Mongo = (
/>
) : (
<div> */}
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
{/* </div> */}
{/* )} */}
</>
)}
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
{/* </div> */}
{/* )} */}
</>
)}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
</TabsContent>
)}
{permissions?.logs.read && (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
)}
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups
@@ -294,11 +316,16 @@ const Mongo = (
/>
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings id={mongoId} type="mongo" />
</div>
</TabsContent>
{permissions?.service.create && (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={mongoId}
type="mongo"
/>
</div>
</TabsContent>
)}
</Tabs>
)}
</CardContent>

View File

@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -58,11 +59,14 @@ const MySql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
@@ -92,7 +96,7 @@ const MySql = (
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
{appName}
</title>
</Head>
<div className="w-full">
@@ -156,10 +160,10 @@ const MySql = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} />
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.create && (
<UpdateMysql mysqlId={mysqlId} />
)}
{permissions?.service.delete && (
<DeleteService id={mysqlId} type="mysql" />
)}
</div>
@@ -211,17 +215,24 @@ const MySql = (
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">
Environment
</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
{permissions?.envVars.read && (
<TabsTrigger value="environment">
Environment
</TabsTrigger>
)}
{permissions?.logs.read && (
<TabsTrigger value="logs">Logs</TabsTrigger>
)}
{permissions?.monitoring.read &&
((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList>
</div>
@@ -232,40 +243,47 @@ const MySql = (
<ShowExternalMysqlCredentials mysqlId={mysqlId} />
</div>
</TabsContent>
<TabsContent value="environment" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={mysqlId} type="mysql" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</>
)}
{permissions?.envVars.read && (
<TabsContent value="environment" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={mysqlId} type="mysql" />
</div>
</div>
</TabsContent>
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token ||
""
}
/>
) : (
<>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</>
)}
</div>
</div>
</TabsContent>
)}
{permissions?.logs.read && (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
)}
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups
@@ -275,14 +293,16 @@ const MySql = (
/>
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={mysqlId}
type="mysql"
/>
</div>
</TabsContent>
{permissions?.service.create && (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={mysqlId}
type="mysql"
/>
</div>
</TabsContent>
)}
</Tabs>
)}
</CardContent>

View File

@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -58,11 +59,14 @@ const Postgresql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
@@ -90,7 +94,8 @@ const Postgresql = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{appName}
</title>
</Head>
<div className="w-full">
@@ -154,10 +159,10 @@ const Postgresql = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} />
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.create && (
<UpdatePostgres postgresId={postgresId} />
)}
{permissions?.service.delete && (
<DeleteService id={postgresId} type="postgres" />
)}
</div>
@@ -211,13 +216,24 @@ const Postgresql = (
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{permissions?.envVars.read && (
<TabsTrigger value="environment">
Environment
</TabsTrigger>
)}
{permissions?.logs.read && (
<TabsTrigger value="logs">Logs</TabsTrigger>
)}
{permissions?.monitoring.read &&
((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList>
</div>
@@ -232,44 +248,50 @@ const Postgresql = (
/>
</div>
</TabsContent>
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={postgresId} type="postgres" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${
data?.serverId
? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}`
: "http://localhost:4500"
}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</>
)}
{permissions?.envVars.read && (
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={postgresId} type="postgres" />
</div>
</div>
</TabsContent>
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${
data?.serverId
? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}`
: "http://localhost:4500"
}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</>
)}
</div>
</div>
</TabsContent>
)}
{permissions?.logs.read && (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
)}
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups
@@ -279,14 +301,16 @@ const Postgresql = (
/>
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={postgresId}
type="postgres"
/>
</div>
</TabsContent>
{permissions?.service.create && (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={postgresId}
type="postgres"
/>
</div>
</TabsContent>
)}
</Tabs>
)}
</CardContent>

View File

@@ -44,6 +44,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type TabState = "projects" | "monitoring" | "settings" | "advanced";
@@ -58,11 +59,14 @@ const Redis = (
const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
});
const { config: whitelabeling } = useWhitelabeling();
const appName = whitelabeling?.appName || "Dokploy";
const environmentDropdownItems =
environments?.map((env) => ({
name: env.name,
@@ -90,7 +94,8 @@ const Redis = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{appName}
</title>
</Head>
<div className="w-full">
@@ -154,10 +159,10 @@ const Redis = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
{permissions?.service.create && (
<UpdateRedis redisId={redisId} />
)}
{permissions?.service.delete && (
<DeleteService id={redisId} type="redis" />
)}
</div>
@@ -209,12 +214,23 @@ const Redis = (
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{permissions?.envVars.read && (
<TabsTrigger value="environment">
Environment
</TabsTrigger>
)}
{permissions?.logs.read && (
<TabsTrigger value="logs">Logs</TabsTrigger>
)}
{permissions?.monitoring.read &&
((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
</div>
@@ -225,25 +241,28 @@ const Redis = (
<ShowExternalRedisCredentials redisId={redisId} />
</div>
</TabsContent>
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={redisId} type="redis" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures && (
{permissions?.envVars.read && (
<TabsContent value="environment">
<div className="flex flex-col gap-4 pt-2.5">
<ShowEnvironment id={redisId} type="redis" />
</div>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg p-6">
{data?.serverId && isCloud ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
token={
data?.server?.metricsConfig?.server?.token || ""
}
/>
) : (
<>
{/* {monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">
Change Monitoring
@@ -265,29 +284,37 @@ const Redis = (
/>
) : (
<div> */}
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
{/* </div> */}
{/* )} */}
</>
)}
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
{/* </div> */}
{/* )} */}
</>
)}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings id={redisId} type="redis" />
</div>
</TabsContent>
</TabsContent>
)}
{permissions?.logs.read && (
<TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
</div>
</TabsContent>
)}
{permissions?.service.create && (
<TabsContent value="advanced">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={redisId}
type="redis"
/>
</div>
</TabsContent>
)}
</Tabs>
)}
</CardContent>

View File

@@ -0,0 +1,66 @@
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 { ShowAuditLogs } from "@/components/proprietary/audit-logs/show-audit-logs";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowAuditLogs />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Audit Logs">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: { destination: "/", permanent: true },
};
}
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,
});
try {
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userPermissions?.auditLog.read) {
return {
redirect: {
destination: "/dashboard/settings/profile",
permanent: false,
},
};
}
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch {
return { props: {} };
}
}

View File

@@ -48,19 +48,15 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userR?.canAccessToGitProviders) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (!userPermissions?.gitProviders.read) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {

View File

@@ -11,7 +11,7 @@ import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const Page = () => {
const { data } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -19,9 +19,7 @@ const Page = () => {
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{isCloud && <LinkingAccount />}
{(data?.canAccessToAPI ||
data?.role === "owner" ||
data?.role === "admin") && <ShowApiKeys />}
{permissions?.api.read && <ShowApiKeys />}
</div>
</div>
);

View File

@@ -49,19 +49,15 @@ export async function getServerSideProps(
await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userR?.canAccessToSSHKeys) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (!userPermissions?.sshKeys.read) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {

View File

@@ -3,16 +3,24 @@ 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 { ManageCustomRoles } from "@/components/proprietary/roles/manage-custom-roles";
import { ShowInvitations } from "@/components/dashboard/settings/users/show-invitations";
import { ShowUsers } from "@/components/dashboard/settings/users/show-users";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const Page = () => {
const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const isOwnerOrAdmin = auth?.role === "owner" || auth?.role === "admin";
const canCreateMembers = permissions?.member.create ?? false;
return (
<div className="flex flex-col gap-4 w-full">
<ShowUsers />
<ShowInvitations />
{canCreateMembers && <ShowInvitations />}
{isOwnerOrAdmin && <ManageCustomRoles />}
</div>
);
};
@@ -28,7 +36,7 @@ export async function getServerSideProps(
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role === "member") {
if (!user) {
return {
redirect: {
permanent: true,
@@ -48,12 +56,30 @@ export async function getServerSideProps(
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
try {
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userPermissions?.member.read) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch {
return {
props: {},
};
}
}

View File

@@ -0,0 +1,81 @@
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 { WhitelabelingSettings } from "@/components/proprietary/whitelabeling/whitelabeling-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Enterprise Whitelabeling",
description:
"Whitelabeling allows you to fully customize logos, colors, CSS, error pages, and more. Add a valid license to configure it.",
ctaLabel: "Go to License",
}}
>
<WhitelabelingSettings />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role !== "owner") {
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(),
},
};
}

View File

@@ -53,19 +53,15 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userR?.canAccessToDocker) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (!userPermissions?.docker.read) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {

View File

@@ -53,19 +53,15 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userR?.canAccessToTraefikFiles) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (!userPermissions?.traefikFiles.read) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {

View File

@@ -41,6 +41,7 @@ import {
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const LoginSchema = z.object({
email: z.string().email(),
@@ -58,6 +59,7 @@ interface Props {
}
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { config: whitelabeling } = useWhitelabelingPublic();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
@@ -216,7 +218,14 @@ export default function Home({ IS_CLOUD }: Props) {
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
<div className="flex flex-row items-center justify-center gap-2">
<Logo className="size-12" />
<Logo
className="size-12"
logoUrl={
whitelabeling?.loginLogoUrl ||
whitelabeling?.logoUrl ||
undefined
}
/>
Sign in
</div>
</h1>

View File

@@ -23,6 +23,7 @@ import {
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
.object({
@@ -82,6 +83,7 @@ const Invitation = ({
userAlreadyExists,
}: Props) => {
const router = useRouter();
const { config: whitelabeling } = useWhitelabelingPublic();
const { data } = api.user.getUserByToken.useQuery(
{
token,
@@ -148,12 +150,15 @@ const Invitation = ({
<div className="flex h-screen w-full items-center justify-center ">
<div className="flex flex-col items-center gap-4 w-full">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Link
href="https://dokploy.com"
target="_blank"
className="flex flex-row items-center gap-2"
>
<Logo className="size-12" />
<Link href="/" className="flex flex-row items-center gap-2">
<Logo
className="size-12"
logoUrl={
whitelabeling?.loginLogoUrl ||
whitelabeling?.logoUrl ||
undefined
}
/>
</Link>
Invitation
</CardTitle>

View File

@@ -25,6 +25,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
.object({
@@ -77,6 +78,7 @@ interface Props {
const Register = ({ isCloud }: Props) => {
const router = useRouter();
const { config: whitelabeling } = useWhitelabelingPublic();
const [isError, setIsError] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<any>(null);
@@ -123,12 +125,15 @@ const Register = ({ isCloud }: Props) => {
<div className="flex w-full items-center justify-center ">
<div className="flex flex-col items-center gap-4 w-full">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Link
href="https://dokploy.com"
target="_blank"
className="flex flex-row items-center gap-2"
>
<Logo className="size-12" />
<Link href="/" className="flex flex-row items-center gap-2">
<Logo
className="size-12"
logoUrl={
whitelabeling?.loginLogoUrl ||
whitelabeling?.logoUrl ||
undefined
}
/>
</Link>
{isCloud ? "Sign Up" : "Setup the server"}
</CardTitle>

View File

@@ -22,6 +22,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z
.object({
@@ -53,6 +54,7 @@ interface Props {
tokenResetPassword: string;
}
export default function Home({ tokenResetPassword }: Props) {
const { config: whitelabeling } = useWhitelabelingPublic();
const [token, setToken] = useState<string | null>(tokenResetPassword);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -97,7 +99,14 @@ export default function Home({ tokenResetPassword }: Props) {
<div className="flex flex-col items-center gap-4 w-full">
<CardTitle className="text-2xl font-bold flex flex-row gap-2 items-center">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo className="size-12" />
<Logo
className="size-12"
logoUrl={
whitelabeling?.loginLogoUrl ||
whitelabeling?.logoUrl ||
undefined
}
/>
</Link>
Reset Password
</CardTitle>

View File

@@ -22,6 +22,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
email: z
@@ -42,6 +43,7 @@ type AuthResponse = {
};
export default function Home() {
const { config: whitelabeling } = useWhitelabelingPublic();
const [temp, _setTemp] = useState<AuthResponse>({
is2FAEnabled: false,
authId: "",
@@ -81,8 +83,14 @@ export default function Home() {
<div className="flex w-full items-center justify-center ">
<div className="flex flex-col items-center gap-4 w-full">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo />
<span className="font-medium text-sm">Dokploy</span>
<Logo
logoUrl={
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
}
/>
<span className="font-medium text-sm">
{whitelabeling?.appName || "Dokploy"}
</span>
</Link>
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
<CardDescription>

View File

@@ -98,19 +98,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
transformer: superjson,
});
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
const userPermissions = await helpers.user.getPermissions.fetch();
if (!userR?.canAccessToAPI) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (!userPermissions?.api.read) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {

View File

@@ -23,8 +23,11 @@ import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
@@ -87,6 +90,9 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,

View File

@@ -21,7 +21,7 @@ import { findProjectById } from "@dokploy/server/services/project";
import {
addNewService,
checkServiceAccess,
} from "@dokploy/server/services/user";
} from "@dokploy/server/services/permission";
import {
getProviderHeaders,
getProviderName,
@@ -38,17 +38,10 @@ import {
import { generatePassword } from "@/templates/utils";
export const aiRouter = createTRPCRouter({
one: protectedProcedure
one: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return aiSetting;
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
getModels: protectedProcedure
@@ -159,11 +152,9 @@ export const aiRouter = createTRPCRouter({
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: protectedProcedure
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
@@ -171,29 +162,15 @@ export const aiRouter = createTRPCRouter({
);
}),
get: protectedProcedure
get: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
return aiSetting;
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
delete: protectedProcedure
delete: adminProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ ctx, input }) => {
const aiSetting = await getAiSettingById(input.aiId);
if (aiSetting.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this AI configuration",
});
}
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
}),
@@ -223,13 +200,7 @@ export const aiRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.session.activeOrganizationId,
environment.projectId,
"create",
);
}
await checkServiceAccess(ctx, environment.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -275,13 +246,7 @@ export const aiRouter = createTRPCRouter({
}
}
if (ctx.user.role === "member") {
await addNewService(
ctx.session.activeOrganizationId,
ctx.user.ownerId,
compose.composeId,
);
}
await addNewService(ctx, compose.composeId);
return null;
}),

View File

@@ -1,13 +1,10 @@
import {
addNewService,
checkServiceAccess,
clearOldDeployments,
createApplication,
deleteAllMiddlewares,
findApplicationById,
findEnvironmentById,
findGitProviderById,
findMemberById,
findProjectById,
getApplicationStats,
IS_CLOUD,
@@ -29,14 +26,24 @@ import {
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
// uploadFileSchema
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateApplication,
apiDeployApplication,
@@ -72,18 +79,10 @@ export const applicationRouter = createTRPCRouter({
.input(apiCreateApplication)
.mutation(async ({ input, ctx }) => {
try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, project.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -101,13 +100,13 @@ export const applicationRouter = createTRPCRouter({
const newApplication = await createApplication(input);
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newApplication.applicationId,
project.organizationId,
);
}
await addNewService(ctx, newApplication.applicationId);
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newApplication.applicationId,
resourceName: newApplication.appName,
});
return newApplication;
} catch (error: unknown) {
console.log("error", error);
@@ -124,14 +123,7 @@ export const applicationRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.applicationId, "read");
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
@@ -186,22 +178,21 @@ export const applicationRouter = createTRPCRouter({
reload: protectedProcedure
.input(apiReloadApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["create"],
});
const application = await findApplicationById(input.applicationId);
try {
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this application",
});
}
await updateApplicationStatus(input.applicationId, "idle");
await mechanizeDockerContainer(application);
await updateApplicationStatus(input.applicationId, "done");
await audit(ctx, {
action: "reload",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
} catch (error) {
await updateApplicationStatus(input.applicationId, "error");
@@ -216,14 +207,7 @@ export const applicationRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.applicationId, "delete");
const application = await findApplicationById(input.applicationId);
if (
@@ -272,69 +256,66 @@ export const applicationRouter = createTRPCRouter({
} catch (_) {}
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: application.applicationId,
resourceName: application.appName,
});
return application;
}),
stop: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["create"],
});
const service = await findApplicationById(input.applicationId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this application",
});
}
if (service.serverId) {
await stopServiceRemote(service.serverId, service.appName);
} else {
await stopService(service.appName);
}
await updateApplicationStatus(input.applicationId, "idle");
await audit(ctx, {
action: "stop",
resourceType: "application",
resourceId: service.applicationId,
resourceName: service.appName,
});
return service;
}),
start: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["create"],
});
const service = await findApplicationById(input.applicationId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this application",
});
}
if (service.serverId) {
await startServiceRemote(service.serverId, service.appName);
} else {
await startService(service.appName);
}
await updateApplicationStatus(input.applicationId, "done");
await audit(ctx, {
action: "start",
resourceType: "application",
resourceId: service.applicationId,
resourceName: service.appName,
});
return service;
}),
redeploy: protectedProcedure
.input(apiRedeployApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["create"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to redeploy this application",
});
}
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: input.title || "Rebuild deployment",
@@ -349,6 +330,12 @@ export const applicationRouter = createTRPCRouter({
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
await audit(ctx, {
action: "rebuild",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}
await myQueue.add(
@@ -359,41 +346,40 @@ export const applicationRouter = createTRPCRouter({
removeOnFail: true,
},
);
await audit(ctx, {
action: "rebuild",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
}),
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariables)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
envVars: ["write"],
});
await updateApplication(input.applicationId, {
env: input.env,
buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
createEnvFile: input.createEnvFile,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
saveBuildType: protectedProcedure
.input(apiSaveBuildType)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this build type",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
buildType: input.buildType,
dockerfile: input.dockerfile,
@@ -404,22 +390,21 @@ export const applicationRouter = createTRPCRouter({
isStaticSpa: input.isStaticSpa,
railpackVersion: input.railpackVersion,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
saveGithubProvider: protectedProcedure
.input(apiSaveGithubProvider)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this github provider",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
repository: input.repository,
branch: input.branch,
@@ -432,22 +417,21 @@ export const applicationRouter = createTRPCRouter({
triggerType: input.triggerType,
enableSubmodules: input.enableSubmodules,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
saveGitlabProvider: protectedProcedure
.input(apiSaveGitlabProvider)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this gitlab provider",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
gitlabRepository: input.gitlabRepository,
gitlabOwner: input.gitlabOwner,
@@ -461,22 +445,21 @@ export const applicationRouter = createTRPCRouter({
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
saveBitbucketProvider: protectedProcedure
.input(apiSaveBitbucketProvider)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this bitbucket provider",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
bitbucketRepository: input.bitbucketRepository,
bitbucketRepositorySlug: input.bitbucketRepositorySlug,
@@ -489,22 +472,21 @@ export const applicationRouter = createTRPCRouter({
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
saveGiteaProvider: protectedProcedure
.input(apiSaveGiteaProvider)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this gitea provider",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
giteaRepository: input.giteaRepository,
giteaOwner: input.giteaOwner,
@@ -516,22 +498,21 @@ export const applicationRouter = createTRPCRouter({
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
saveDockerProvider: protectedProcedure
.input(apiSaveDockerProvider)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this docker provider",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
dockerImage: input.dockerImage,
username: input.username,
@@ -540,22 +521,21 @@ export const applicationRouter = createTRPCRouter({
applicationStatus: "idle",
registryUrl: input.registryUrl,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
saveGitProvider: protectedProcedure
.input(apiSaveGitProvider)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this git provider",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
customGitBranch: input.customGitBranch,
customGitBuildPath: input.customGitBuildPath,
@@ -566,26 +546,22 @@ export const applicationRouter = createTRPCRouter({
watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
disconnectGitProvider: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to disconnect this git provider",
});
}
// Reset all git provider related fields
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
// GitHub fields
repository: null,
branch: null,
owner: null,
@@ -593,7 +569,6 @@ export const applicationRouter = createTRPCRouter({
githubId: null,
triggerType: "push",
// GitLab fields
gitlabRepository: null,
gitlabOwner: null,
gitlabBranch: null,
@@ -602,63 +577,58 @@ export const applicationRouter = createTRPCRouter({
gitlabProjectId: null,
gitlabPathNamespace: null,
// Bitbucket fields
bitbucketRepository: null,
bitbucketOwner: null,
bitbucketBranch: null,
bitbucketBuildPath: null,
bitbucketId: null,
// Gitea fields
giteaRepository: null,
giteaOwner: null,
giteaBranch: null,
giteaBuildPath: null,
giteaId: null,
// Custom Git fields
customGitBranch: null,
customGitBuildPath: null,
customGitUrl: null,
customGitSSHKeyId: null,
// Common fields
sourceType: "github", // Reset to default
applicationStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
markRunning: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to mark this application as running",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["create"],
});
await updateApplicationStatus(input.applicationId, "running");
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "deploy",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
}),
update: protectedProcedure
.input(apiUpdateApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
const { applicationId, ...rest } = input;
const updateApp = await updateApplication(applicationId, {
...rest,
@@ -670,40 +640,39 @@ export const applicationRouter = createTRPCRouter({
message: "Error updating application",
});
}
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: updateApp.applicationId,
resourceName: updateApp.appName,
});
return true;
}),
refreshToken: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to refresh this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
await updateApplication(input.applicationId, {
refreshToken: nanoid(),
});
const application = await findApplicationById(input.applicationId);
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
deploy: protectedProcedure
.input(apiDeployApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["create"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this application",
});
}
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: input.title || "Manual deployment",
@@ -717,7 +686,12 @@ export const applicationRouter = createTRPCRouter({
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
await audit(ctx, {
action: "deploy",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}
await myQueue.add(
@@ -728,69 +702,60 @@ export const applicationRouter = createTRPCRouter({
removeOnFail: true,
},
);
await audit(ctx, {
action: "deploy",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
}),
cleanQueues: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to clean this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["cancel"],
});
await cleanQueuesByApplication(input.applicationId);
}),
clearDeployments: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["create"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this application",
});
}
await clearOldDeployments(application.appName, application.serverId);
await audit(ctx, {
action: "delete",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["cancel"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to kill this build",
});
}
await killDockerBuild("application", application.serverId);
await audit(ctx, {
action: "stop",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
}),
readTraefikConfig: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
traefikFiles: ["read"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to read this application",
});
}
let traefikConfig = null;
if (application.serverId) {
traefikConfig = await readRemoteConfig(
@@ -820,18 +785,11 @@ export const applicationRouter = createTRPCRouter({
const applicationId = formData.get("applicationId") as string;
const dropBuildPath = formData.get("dropBuildPath") as string | null;
await checkServicePermissionAndAccess(ctx, applicationId, {
deployment: ["create"],
});
const app = await findApplicationById(applicationId);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this application",
});
}
await updateApplication(applicationId, {
sourceType: "drop",
dropBuildPath: dropBuildPath || "",
@@ -862,23 +820,21 @@ export const applicationRouter = createTRPCRouter({
removeOnFail: true,
},
);
await audit(ctx, {
action: "deploy",
resourceType: "application",
resourceId: app.applicationId,
resourceName: app.appName,
});
return true;
}),
updateTraefikConfig: protectedProcedure
.input(z.object({ applicationId: z.string(), traefikConfig: z.string() }))
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
traefikFiles: ["write"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this application",
});
}
if (application.serverId) {
await writeConfigRemote(
application.serverId,
@@ -888,9 +844,15 @@ export const applicationRouter = createTRPCRouter({
} else {
writeConfig(application.appName, input.traefikConfig);
}
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return true;
}),
readAppMonitoring: protectedProcedure
readAppMonitoring: withPermission("monitoring", "read")
.input(apiFindMonitoringStats)
.query(async ({ input }) => {
if (IS_CLOUD) {
@@ -911,31 +873,10 @@ export const applicationRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this application",
});
}
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["create"],
});
const targetEnvironment = await findEnvironmentById(
input.targetEnvironmentId,
);
if (
targetEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this environment",
});
}
// Update the application's projectId
const updatedApplication = await db
.update(applications)
.set({
@@ -951,23 +892,22 @@ export const applicationRouter = createTRPCRouter({
message: "Failed to move application",
});
}
await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: updatedApplication.applicationId,
resourceName: updatedApplication.appName,
});
return updatedApplication;
}),
cancelDeployment: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
deployment: ["cancel"],
});
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to cancel this deployment",
});
}
if (IS_CLOUD && application.serverId) {
try {
@@ -984,7 +924,12 @@ export const applicationRouter = createTRPCRouter({
applicationId: input.applicationId,
applicationType: "application",
});
await audit(ctx, {
action: "stop",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
return {
success: true,
message: "Deployment cancellation requested",
@@ -1085,19 +1030,17 @@ export const applicationRouter = createTRPCRouter({
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${applications.applicationId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const { accessedServices } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${applications.applicationId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);

View File

@@ -44,7 +44,13 @@ import {
} from "@dokploy/server/utils/restore";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateBackup,
apiFindOneBackup,
@@ -69,10 +75,21 @@ interface RcloneFile {
export const backupRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const newBackup = await createBackup(input);
const serviceId =
input.postgresId ||
input.mysqlId ||
input.mariadbId ||
input.mongoId ||
input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["create"],
});
}
const newBackup = await createBackup(input);
const backup = await findBackupById(newBackup.backupId);
if (IS_CLOUD && backup.enabled) {
@@ -110,6 +127,11 @@ export const backupRouter = createTRPCRouter({
scheduleBackup(backup);
}
}
await audit(ctx, {
action: "create",
resourceType: "backup",
resourceId: backup.backupId,
});
} catch (error) {
console.error(error);
throw new TRPCError({
@@ -122,15 +144,42 @@ export const backupRouter = createTRPCRouter({
});
}
}),
one: protectedProcedure.input(apiFindOneBackup).query(async ({ input }) => {
const backup = await findBackupById(input.backupId);
one: protectedProcedure
.input(apiFindOneBackup)
.query(async ({ input, ctx }) => {
const backup = await findBackupById(input.backupId);
return backup;
}),
const serviceId =
backup.postgresId ||
backup.mysqlId ||
backup.mariadbId ||
backup.mongoId ||
backup.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["read"],
});
}
return backup;
}),
update: protectedProcedure
.input(apiUpdateBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const existing = await findBackupById(input.backupId);
const serviceId =
existing.postgresId ||
existing.mysqlId ||
existing.mariadbId ||
existing.mongoId ||
existing.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["update"],
});
}
await updateBackupById(input.backupId, input);
const backup = await findBackupById(input.backupId);
@@ -156,6 +205,11 @@ export const backupRouter = createTRPCRouter({
removeScheduleBackup(input.backupId);
}
}
await audit(ctx, {
action: "update",
resourceType: "backup",
resourceId: backup.backupId,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Error updating this Backup";
@@ -167,8 +221,21 @@ export const backupRouter = createTRPCRouter({
}),
remove: protectedProcedure
.input(apiRemoveBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
const serviceId =
backup.postgresId ||
backup.mysqlId ||
backup.mariadbId ||
backup.mongoId ||
backup.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["delete"],
});
}
const value = await removeBackupById(input.backupId);
if (IS_CLOUD && value) {
removeJob({
@@ -179,6 +246,11 @@ export const backupRouter = createTRPCRouter({
} else if (!IS_CLOUD) {
removeScheduleBackup(input.backupId);
}
await audit(ctx, {
action: "delete",
resourceType: "backup",
resourceId: input.backupId,
});
return value;
} catch (error) {
const message =
@@ -191,13 +263,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupPostgres: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.postgresId) {
await checkServicePermissionAndAccess(ctx, backup.postgresId, {
backup: ["create"],
});
}
const postgres = await findPostgresByBackupId(backup.backupId);
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
const message =
@@ -213,12 +294,22 @@ export const backupRouter = createTRPCRouter({
manualBackupMySql: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.mysqlId) {
await checkServicePermissionAndAccess(ctx, backup.mysqlId, {
backup: ["create"],
});
}
const mysql = await findMySqlByBackupId(backup.backupId);
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -230,12 +321,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupMariadb: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.mariadbId) {
await checkServicePermissionAndAccess(ctx, backup.mariadbId, {
backup: ["create"],
});
}
const mariadb = await findMariadbByBackupId(backup.backupId);
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -247,12 +348,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupCompose: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.composeId) {
await checkServicePermissionAndAccess(ctx, backup.composeId, {
backup: ["create"],
});
}
const compose = await findComposeByBackupId(backup.backupId);
await runComposeBackup(compose, backup);
await keepLatestNBackups(backup, compose?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -264,12 +375,22 @@ export const backupRouter = createTRPCRouter({
}),
manualBackupMongo: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const backup = await findBackupById(input.backupId);
if (backup.mongoId) {
await checkServicePermissionAndAccess(ctx, backup.mongoId, {
backup: ["create"],
});
}
const mongo = await findMongoByBackupId(backup.backupId);
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo?.serverId);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -279,15 +400,20 @@ export const backupRouter = createTRPCRouter({
});
}
}),
manualBackupWebServer: protectedProcedure
manualBackupWebServer: withPermission("backup", "create")
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
const backup = await findBackupById(input.backupId);
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
await audit(ctx, {
action: "run",
resourceType: "backup",
resourceId: backup.backupId,
});
return true;
}),
listBackupFiles: protectedProcedure
listBackupFiles: withPermission("backup", "read")
.input(
z.object({
destinationId: z.string(),
@@ -374,7 +500,12 @@ export const backupRouter = createTRPCRouter({
},
})
.input(apiRestoreBackup)
.subscription(async function* ({ input, signal }) {
.subscription(async function* ({ input, ctx, signal }) {
if (input.databaseId) {
await checkServicePermissionAndAccess(ctx, input.databaseId, {
backup: ["restore"],
});
}
const destination = await findDestinationById(input.destinationId);
const queue: string[] = [];
const done = false;

View File

@@ -8,7 +8,12 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiBitbucketTestConnection,
apiCreateBitbucket,
@@ -18,15 +23,23 @@ import {
} from "@/server/db/schema";
export const bitbucketRouter = createTRPCRouter({
create: protectedProcedure
create: withPermission("gitProviders", "create")
.input(apiCreateBitbucket)
.mutation(async ({ input, ctx }) => {
try {
return await createBitbucket(
const result = await createBitbucket(
input,
ctx.session.activeOrganizationId,
ctx.session.userId,
);
await audit(ctx, {
action: "create",
resourceType: "gitProvider",
resourceName: input.name,
});
return result;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -37,19 +50,8 @@ export const bitbucketRouter = createTRPCRouter({
}),
one: protectedProcedure
.input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
return bitbucketProvider;
.query(async ({ input }) => {
return await findBitbucketById(input.bitbucketId);
}),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
let result = await db.query.bitbucket.findMany({
@@ -73,53 +75,18 @@ export const bitbucketRouter = createTRPCRouter({
getBitbucketRepositories: protectedProcedure
.input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
.query(async ({ input }) => {
return await getBitbucketRepositories(input.bitbucketId);
}),
getBitbucketBranches: protectedProcedure
.input(apiFindBitbucketBranches)
.query(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(
input.bitbucketId || "",
);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
.query(async ({ input }) => {
return await getBitbucketBranches(input);
}),
testConnection: protectedProcedure
.input(apiBitbucketTestConnection)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
const result = await testBitbucketConnection(input);
return `Found ${result} repositories`;
@@ -130,23 +97,21 @@ export const bitbucketRouter = createTRPCRouter({
});
}
}),
update: protectedProcedure
update: withPermission("gitProviders", "create")
.input(apiUpdateBitbucket)
.mutation(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId);
if (
bitbucketProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
bitbucketProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this bitbucket provider",
});
}
return await updateBitbucket(input.bitbucketId, {
const result = await updateBitbucket(input.bitbucketId, {
...input,
organizationId: ctx.session.activeOrganizationId,
});
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.bitbucketId,
resourceName: input.name,
});
return result;
}),
});

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