Compare commits

..

96 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
183 changed files with 27235 additions and 6101 deletions

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

@@ -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,14 +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 } = api.organization.active.useQuery();
const { data: session } = authClient.useSession();
const { data: session } = api.user.session.useQuery();
const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
@@ -99,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"
>

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 } = api.organization.active.useQuery();
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,6 +545,7 @@ function SidebarLogo() {
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
api.organization.setDefault.useMutation();
const { isMobile } = useSidebar();
const isCollapsed = state === "collapsed" && !isMobile;
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: invitations, refetch: refetchInvitations } =
@@ -582,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 */}
@@ -592,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
@@ -614,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}
/>
@@ -622,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">
@@ -631,7 +618,7 @@ function SidebarLogo() {
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", state === "collapsed" && "hidden")}
className={cn("ml-auto", isCollapsed && "hidden")}
/>
</SidebarMenuButton>
</DropdownMenuTrigger>
@@ -780,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
@@ -788,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" />
@@ -884,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();
@@ -893,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],
@@ -1133,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>
@@ -1141,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.4",
"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;
}),
});

View File

@@ -7,7 +7,8 @@ import {
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { adminProcedure, createTRPCRouter } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import {
apiCreateCertificate,
apiFindCertificate,
@@ -15,7 +16,7 @@ import {
} from "@/server/db/schema";
export const certificateRouter = createTRPCRouter({
create: adminProcedure
create: withPermission("certificate", "create")
.input(apiCreateCertificate)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD && !input.serverId) {
@@ -24,10 +25,20 @@ export const certificateRouter = createTRPCRouter({
message: "Please set a server to create a certificate",
});
}
return await createCertificate(input, ctx.session.activeOrganizationId);
const cert = await createCertificate(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "certificate",
resourceId: cert.certificateId,
resourceName: cert.name,
});
return cert;
}),
one: adminProcedure
one: withPermission("certificate", "read")
.input(apiFindCertificate)
.query(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId);
@@ -39,7 +50,7 @@ export const certificateRouter = createTRPCRouter({
}
return certificates;
}),
remove: adminProcedure
remove: withPermission("certificate", "delete")
.input(apiFindCertificate)
.mutation(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId);
@@ -49,10 +60,16 @@ export const certificateRouter = createTRPCRouter({
message: "You are not allowed to delete this certificate",
});
}
await audit(ctx, {
action: "delete",
resourceType: "certificate",
resourceId: certificates.certificateId,
resourceName: certificates.name,
});
await removeCertificateById(input.certificateId);
return true;
}),
all: adminProcedure.query(async ({ ctx }) => {
all: withPermission("certificate", "read").query(async ({ ctx }) => {
return await db.query.certificates.findMany({
where: eq(certificates.organizationId, ctx.session.activeOrganizationId),
});

View File

@@ -7,10 +7,12 @@ import {
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { getLocalServerIp } from "@/server/wss/terminal";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, withPermission } from "../trpc";
export const clusterRouter = createTRPCRouter({
getNodes: protectedProcedure
getNodes: withPermission("server", "read")
.input(
z.object({
serverId: z.string().optional(),
@@ -19,17 +21,17 @@ export const clusterRouter = createTRPCRouter({
.query(async ({ input }) => {
const docker = await getRemoteDocker(input.serverId);
const workers: DockerNode[] = await docker.listNodes();
return workers;
}),
removeWorker: protectedProcedure
removeWorker: withPermission("server", "delete")
.input(
z.object({
nodeId: z.string(),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
const removeCommand = `docker node rm ${input.nodeId} --force`;
@@ -41,6 +43,12 @@ export const clusterRouter = createTRPCRouter({
await execAsync(drainCommand);
await execAsync(removeCommand);
}
await audit(ctx, {
action: "delete",
resourceType: "cluster",
resourceId: input.nodeId,
resourceName: input.nodeId,
});
return true;
} catch (error) {
throw new TRPCError({
@@ -50,7 +58,8 @@ export const clusterRouter = createTRPCRouter({
});
}
}),
addWorker: protectedProcedure
addWorker: withPermission("server", "create")
.input(
z.object({
serverId: z.string().optional(),
@@ -68,13 +77,12 @@ export const clusterRouter = createTRPCRouter({
}
return {
command: `docker swarm join --token ${
result.JoinTokens.Worker
} ${ip}:2377`,
command: `docker swarm join --token ${result.JoinTokens.Worker} ${ip}:2377`,
version: docker_version.Version,
};
}),
addManager: protectedProcedure
addManager: withPermission("server", "create")
.input(
z.object({
serverId: z.string().optional(),
@@ -91,9 +99,7 @@ export const clusterRouter = createTRPCRouter({
ip = server?.ipAddress;
}
return {
command: `docker swarm join --token ${
result.JoinTokens.Manager
} ${ip}:2377`,
command: `docker swarm join --token ${result.JoinTokens.Manager} ${ip}:2377`,
version: docker_version.Version,
};
}),

View File

@@ -1,7 +1,5 @@
import {
addDomainToCompose,
addNewService,
checkServiceAccess,
clearOldDeployments,
cloneCompose,
createCommand,
@@ -16,7 +14,6 @@ import {
findDomainsByComposeId,
findEnvironmentById,
findGitProviderById,
findMemberById,
findProjectById,
findServerById,
getComposeContainer,
@@ -34,6 +31,12 @@ import {
updateCompose,
updateDeploymentStatus,
} from "@dokploy/server";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { db } from "@dokploy/server/db";
import {
type CompleteTemplate,
@@ -72,6 +75,7 @@ import {
} from "@/server/queues/queueSetup";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { generatePassword } from "@/templates/utils";
import { audit } from "../utils/audit";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const composeRouter = createTRPCRouter({
@@ -79,18 +83,10 @@ export const composeRouter = createTRPCRouter({
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
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({
@@ -108,14 +104,14 @@ export const composeRouter = createTRPCRouter({
...input,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
newService.composeId,
project.organizationId,
);
}
await addNewService(ctx, newService.composeId);
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newService.composeId,
resourceName: newService.appName,
});
return newService;
} catch (error) {
throw error;
@@ -125,14 +121,7 @@ export const composeRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
ctx.session.activeOrganizationId,
"access",
);
}
await checkServiceAccess(ctx, input.composeId, "read");
const compose = await findComposeById(input.composeId);
if (
@@ -188,29 +177,22 @@ export const composeRouter = createTRPCRouter({
update: protectedProcedure
.input(apiUpdateCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this compose",
});
}
return updateCompose(input.composeId, input);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const updated = await updateCompose(input.composeId, input);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: updated?.name,
});
return updated;
}),
delete: protectedProcedure
.input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
ctx.session.activeOrganizationId,
"delete",
);
}
await checkServiceAccess(ctx, input.composeId, "delete");
const composeResult = await findComposeById(input.composeId);
if (
@@ -249,70 +231,55 @@ export const composeRouter = createTRPCRouter({
} catch (_) {}
}
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: composeResult.composeId,
resourceName: composeResult.appName,
});
return composeResult;
}),
cleanQueues: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to clean this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message:
"You are not authorized to clear deployments for this compose",
});
}
await clearOldDeployments(compose.appName, compose.serverId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return true;
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["cancel"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to kill this build",
});
}
await killDockerBuild("compose", compose.serverId);
}),
loadServices: protectedProcedure
.input(apiFetchServices)
.query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to load this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
return await loadServices(input.composeId, input.type);
}),
loadMountsByService: protectedProcedure
@@ -323,16 +290,10 @@ export const composeRouter = createTRPCRouter({
}),
)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to load this compose",
});
}
const container = await getComposeContainer(compose, input.serviceName);
const mounts = container?.Mounts.filter(
(mount) => mount.Type === "volume" && mount.Source !== "",
@@ -343,18 +304,11 @@ export const composeRouter = createTRPCRouter({
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
try {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to fetch this compose",
});
}
const command = await cloneCompose(compose);
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
@@ -374,49 +328,45 @@ export const composeRouter = createTRPCRouter({
randomizeCompose: protectedProcedure
.input(apiRandomizeCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const result = await randomizeComposeFile(input.composeId, input.suffix);
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to randomize this compose",
});
}
return await randomizeComposeFile(input.composeId, input.suffix);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return result;
}),
isolatedDeployment: protectedProcedure
.input(apiRandomizeCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to randomize this compose",
});
}
return await randomizeIsolatedDeploymentComposeFile(
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const result = await randomizeIsolatedDeploymentComposeFile(
input.composeId,
input.suffix,
);
const compose = await findComposeById(input.composeId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return result;
}),
getConvertedCompose: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to get this compose",
});
}
const domains = await findDomainsByComposeId(input.composeId);
const composeFile = await addDomainToCompose(compose, domains);
return stringify(composeFile, {
@@ -427,17 +377,11 @@ export const composeRouter = createTRPCRouter({
deploy: protectedProcedure
.input(apiDeployCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this compose",
});
}
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: input.title || "Manual deployment",
@@ -452,6 +396,12 @@ export const composeRouter = createTRPCRouter({
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return true;
}
await myQueue.add(
@@ -462,6 +412,12 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return {
success: true,
message: "Deployment queued",
@@ -471,16 +427,10 @@ export const composeRouter = createTRPCRouter({
redeploy: protectedProcedure
.input(apiRedeployCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to redeploy this compose",
});
}
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: input.title || "Rebuild deployment",
@@ -494,6 +444,12 @@ export const composeRouter = createTRPCRouter({
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return true;
}
await myQueue.add(
@@ -504,6 +460,12 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
await audit(ctx, {
action: "deploy",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return {
success: true,
message: "Redeployment queued",
@@ -513,70 +475,61 @@ export const composeRouter = createTRPCRouter({
stop: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
await stopCompose(input.composeId);
const composeForStop = await findComposeById(input.composeId);
await audit(ctx, {
action: "stop",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForStop.name,
});
return true;
}),
start: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["create"],
});
await startCompose(input.composeId);
const composeForStart = await findComposeById(input.composeId);
await audit(ctx, {
action: "start",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForStart.name,
});
return true;
}),
getDefaultCommand: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to get this compose",
});
}
const command = createCommand(compose);
return `docker ${command}`;
}),
refreshToken: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to refresh this compose",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
await updateCompose(input.composeId, {
refreshToken: nanoid(),
});
const composeForToken = await findComposeById(input.composeId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForToken.name,
});
return true;
}),
deployTemplate: protectedProcedure
@@ -591,14 +544,7 @@ export const composeRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
environment.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
await checkServiceAccess(ctx, environment.projectId, "create");
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -648,13 +594,7 @@ export const composeRouter = createTRPCRouter({
isolatedDeployment: true,
});
if (ctx.user.role === "member") {
await addNewService(
ctx.user.id,
compose.composeId,
ctx.session.activeOrganizationId,
);
}
await addNewService(ctx, compose.composeId);
if (generate.mounts && generate.mounts?.length > 0) {
for (const mount of generate.mounts) {
@@ -681,6 +621,12 @@ export const composeRouter = createTRPCRouter({
}
}
await audit(ctx, {
action: "create",
resourceType: "compose",
resourceId: compose.composeId,
resourceName: compose.name,
});
return compose;
}),
@@ -714,20 +660,11 @@ export const composeRouter = createTRPCRouter({
disconnectGitProvider: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to disconnect this git provider",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
// Reset all git provider related fields
await updateCompose(input.composeId, {
// GitHub fields
repository: null,
branch: null,
owner: null,
@@ -735,7 +672,6 @@ export const composeRouter = createTRPCRouter({
githubId: null,
triggerType: "push",
// GitLab fields
gitlabRepository: null,
gitlabOwner: null,
gitlabBranch: null,
@@ -743,30 +679,33 @@ export const composeRouter = createTRPCRouter({
gitlabProjectId: null,
gitlabPathNamespace: null,
// Bitbucket fields
bitbucketRepository: null,
bitbucketOwner: null,
bitbucketBranch: null,
bitbucketId: null,
// Gitea fields
giteaRepository: null,
giteaOwner: null,
giteaBranch: null,
giteaId: null,
// Custom Git fields
customGitBranch: null,
customGitUrl: null,
customGitSSHKeyId: null,
// Common fields
sourceType: "github", // Reset to default
composeStatus: "idle",
watchPaths: null,
enableSubmodules: false,
});
const composeForDisconnect = await findComposeById(input.composeId);
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: composeForDisconnect.name,
});
return true;
}),
@@ -778,29 +717,9 @@ export const composeRouter = createTRPCRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this compose",
});
}
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",
});
}
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const updatedCompose = await db
.update(composeTable)
@@ -818,6 +737,12 @@ export const composeRouter = createTRPCRouter({
});
}
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: updatedCompose.name,
});
return updatedCompose;
}),
@@ -830,18 +755,11 @@ export const composeRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this compose",
});
}
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
@@ -901,21 +819,14 @@ export const composeRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["create"],
});
const compose = await findComposeById(input.composeId);
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this compose",
});
}
for (const mount of compose.mounts) {
await deleteMount(mount.mountId);
}
@@ -993,6 +904,12 @@ export const composeRouter = createTRPCRouter({
}
}
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.appName,
});
return {
success: true,
message: "Template imported successfully",
@@ -1008,16 +925,10 @@ export const composeRouter = createTRPCRouter({
cancelDeployment: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
deployment: ["cancel"],
});
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to cancel this deployment",
});
}
if (IS_CLOUD && compose.serverId) {
try {
@@ -1037,6 +948,12 @@ export const composeRouter = createTRPCRouter({
applicationType: "compose",
});
await audit(ctx, {
action: "stop",
resourceType: "compose",
resourceId: input.composeId,
resourceName: compose.name,
});
return {
success: true,
message: "Deployment cancellation requested",
@@ -1113,19 +1030,17 @@ export const composeRouter = 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`${composeTable.composeId} 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`${composeTable.composeId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
const where = and(...baseConditions);

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