Compare commits

...

20 Commits

Author SHA1 Message Date
dosubot[bot]
d287e2a56d docs: restructure database update endpoints documentation 2026-03-16 22:22:27 +00: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
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
131 changed files with 18314 additions and 5733 deletions

View File

@@ -6,3 +6,377 @@ npm run dev
``` ```
open http://localhost:3000 open http://localhost:3000
``` ```
## Environment Variables
The API server requires the following environment variables for configuration:
### Inngest Configuration
Required for the GET /jobs endpoint to list deployment jobs:
- **INNGEST_BASE_URL** - The base URL for the Inngest instance
- Self-hosted: `http://localhost:8288`
- Production: `https://dev-inngest.dokploy.com`
- **INNGEST_SIGNING_KEY** - The signing key for authenticating with Inngest
Optional configuration for filtering and pagination:
- **INNGEST_EVENTS_RECEIVED_AFTER** (optional) - An RFC3339 timestamp to filter events received after a specific date (e.g., `2024-01-01T00:00:00Z`). If unset, no date filter is applied.
- **INNGEST_JOBS_MAX_EVENTS** (optional) - Maximum number of events to fetch when listing jobs. Default is 100, maximum is 10000. Used for pagination with cursor.
### Lemon Squeezy Integration
- **LEMON_SQUEEZY_API_KEY** - API key for Lemon Squeezy integration
- **LEMON_SQUEEZY_STORE_ID** - Store ID for Lemon Squeezy integration
### Docker Configuration
Optional configuration for customizing Docker daemon connections:
- **DOCKER_API_VERSION** (optional) - Specifies which Docker API version to use when connecting to the Docker daemon. If not set, the Docker client uses the default API version.
- **DOKPLOY_DOCKER_HOST** (optional) - Specifies the Docker daemon host to connect to. If not set, uses the default Docker socket connection.
- **DOKPLOY_DOCKER_PORT** (optional) - Specifies the port for connecting to the Docker daemon. If not set, uses the default port.
These variables allow advanced users to customize how the Dokploy API server connects to Docker, which can be useful for connecting to remote Docker daemons or using specific API versions.
## API Endpoints
### GET /jobs
Lists deployment jobs (Inngest runs) for a specified server.
**Query Parameters:**
- `serverId` (required) - The ID of the server to list deployment jobs for
**Response:**
Returns an array of deployment job objects with the same shape as BullMQ queue jobs:
```json
[
{
"id": "string",
"name": "string",
"data": {},
"timestamp": 0,
"processedOn": 0,
"finishedOn": 0,
"failedReason": "string",
"state": "string"
}
]
```
**Error Responses:**
- `400` - serverId is not provided
- `503` - INNGEST_BASE_URL is not configured
- `200` - Empty array on other errors
This endpoint is used by the UI to display deployment queue information in the dashboard.
## Search Endpoints
The following search endpoints provide flexible querying capabilities with pagination support. All search endpoints respect member permissions, returning only resources the user has access to.
### application.search
Search applications across name, appName, description, repository, owner, and dockerImage fields.
**Query Parameters:**
- `q` (optional string) - General search term that searches across name, appName, description, repository, owner, and dockerImage
- `name` (optional string) - Filter by application name
- `appName` (optional string) - Filter by app name
- `description` (optional string) - Filter by description
- `repository` (optional string) - Filter by repository
- `owner` (optional string) - Filter by owner
- `dockerImage` (optional string) - Filter by Docker image
- `projectId` (optional string) - Filter by project ID
- `environmentId` (optional string) - Filter by environment ID
- `limit` (number, default 20, min 1, max 100) - Maximum number of results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"applicationId": "string",
"name": "string",
"appName": "string",
"description": "string",
"environmentId": "string",
"applicationStatus": "string",
"sourceType": "string",
"createdAt": "string"
}
],
"total": 0
}
```
### compose.search
Search compose services with filtering by name, appName, and description.
**Query Parameters:**
- `q` (optional string) - General search term across name, appName, description
- `name` (optional string) - Filter by name
- `appName` (optional string) - Filter by app name
- `description` (optional string) - Filter by description
- `projectId` (optional string) - Filter by project ID
- `environmentId` (optional string) - Filter by environment ID
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"composeId": "string",
"name": "string",
"appName": "string",
"description": "string",
"environmentId": "string",
"composeStatus": "string",
"sourceType": "string",
"createdAt": "string"
}
],
"total": 0
}
```
### environment.search
Search environments by name and description.
**Query Parameters:**
- `q` (optional string) - General search term across name and description
- `name` (optional string) - Filter by name
- `description` (optional string) - Filter by description
- `projectId` (optional string) - Filter by project ID
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"environmentId": "string",
"name": "string",
"description": "string",
"createdAt": "string",
"env": "string",
"projectId": "string",
"isDefault": true
}
],
"total": 0
}
```
### project.search
Search projects by name and description.
**Query Parameters:**
- `q` (optional string) - General search term across name and description
- `name` (optional string) - Filter by name
- `description` (optional string) - Filter by description
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"projectId": "string",
"name": "string",
"description": "string",
"createdAt": "string",
"organizationId": "string",
"env": "string"
}
],
"total": 0
}
```
### Database Service Search Endpoints
The following database services all share the same search interface:
- **postgres.search**
- **mysql.search**
- **mariadb.search**
- **mongo.search**
- **redis.search**
**Query Parameters:**
- `q` (optional string) - General search term across name, appName, description
- `name` (optional string) - Filter by name
- `appName` (optional string) - Filter by app name
- `description` (optional string) - Filter by description
- `projectId` (optional string) - Filter by project ID
- `environmentId` (optional string) - Filter by environment ID
- `limit` (number, default 20, min 1, max 100) - Maximum results
- `offset` (number, default 0, min 0) - Pagination offset
**Response:**
```json
{
"items": [
{
"postgresId": "string",
"name": "string",
"appName": "string",
"description": "string",
"environmentId": "string",
"applicationStatus": "string",
"createdAt": "string"
}
],
"total": 0
}
```
*Note: The response shape is similar across all database services, with the ID field varying (e.g., `mysqlId`, `mariadbId`, `mongoId`, `redisId`).*
**Search Behavior:**
- All searches use case-insensitive pattern matching with wildcards
- Results are ordered by creation date (descending)
- Members only see services they have access to
- Returns total count for pagination UI
## Database Service Update Endpoints
The following database services support update operations with similar parameters:
- **postgres.update** (apiUpdatePostgres)
- **mysql.update** (apiUpdateMySql)
- **mariadb.update** (apiUpdateMariaDB)
- **mongo.update** (apiUpdateMongo)
- **redis.update** (apiUpdateRedis)
**Input Parameters:**
Each update endpoint accepts the following parameters (all optional except the service ID):
- `postgresId` / `mysqlId` / `mariadbId` / `mongoId` / `redisId` (required) - The ID of the database service to update
- `dockerImage` (optional string) - Custom Docker image to use for the database service
- Other service-specific parameters as defined in the schema
**Example Input:**
```json
{
"postgresId": "string",
"dockerImage": "postgres:16-alpine"
}
```
**Response:**
Returns the updated database service object.
**Purpose:**
The `dockerImage` parameter allows users to specify custom Docker images for database services, providing flexibility for version selection, custom builds, or alternative distributions.
## Whitelabeling Endpoints
The whitelabeling endpoints allow enterprise/self-hosted Dokploy instances to customize branding, logos, colors, and UI appearance. These endpoints are only available in self-hosted mode (not cloud).
### whitelabeling.get
Get the current whitelabeling configuration.
**Requirements:**
- Enterprise license required
- Only available for self-hosted (not cloud)
**Response:**
Returns the whitelabeling configuration object or null if not configured.
```json
{
"appName": "string | null",
"appDescription": "string | null",
"logoUrl": "string | null",
"faviconUrl": "string | null",
"primaryColor": "string | null",
"customCss": "string | null",
"loginLogoUrl": "string | null",
"supportUrl": "string | null",
"docsUrl": "string | null",
"errorPageTitle": "string | null",
"errorPageDescription": "string | null",
"metaTitle": "string | null",
"footerText": "string | null"
}
```
### whitelabeling.update
Update the whitelabeling configuration.
**Requirements:**
- Enterprise license required
- Owner role required
- Only available for self-hosted (not cloud)
**Input:**
```json
{
"whitelabelingConfig": {
"appName": "string | null",
"appDescription": "string | null",
"logoUrl": "string | null",
"faviconUrl": "string | null",
"primaryColor": "string | null",
"customCss": "string | null",
"loginLogoUrl": "string | null",
"supportUrl": "string | null",
"docsUrl": "string | null",
"errorPageTitle": "string | null",
"errorPageDescription": "string | null",
"metaTitle": "string | null",
"footerText": "string | null"
}
}
```
**Response:**
```json
{
"success": true
}
```
### whitelabeling.reset
Reset whitelabeling configuration to default values (all fields set to null).
**Requirements:**
- Enterprise license required
- Owner role required
- Only available for self-hosted (not cloud)
**Response:**
```json
{
"success": true
}
```
### whitelabeling.getPublic
Public endpoint to fetch whitelabeling configuration. This endpoint can be accessed without authentication, allowing the whitelabeling settings to be applied globally (including on the login page before auth).
**Requirements:**
- No authentication required
- Only available for self-hosted (not cloud)
**Response:**
Returns the whitelabeling configuration object or null if not configured. Response shape is identical to `whitelabeling.get`.

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

@@ -15,13 +15,17 @@ interface Props {
} }
export const ShowTraefikConfig = ({ applicationId }: 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( const { data, isPending } = api.application.readTraefikConfig.useQuery(
{ {
applicationId, applicationId,
}, },
{ enabled: !!applicationId }, { enabled: !!applicationId && canRead },
); );
if (!canRead) return null;
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row justify-between"> <CardHeader className="flex flex-row justify-between">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ interface Props {
} }
export const ShowGeneralMongo = ({ mongoId }: 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( const { data, refetch } = api.mongo.one.useQuery(
{ {
mongoId, mongoId,
@@ -73,153 +75,158 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction {canDeploy && (
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" ? (
<DialogAction <DialogAction
title="Start Mongo" title="Deploy Mongo"
description="Are you sure you want to start this mongo?" description="Are you sure you want to deploy this mongo?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
mongoId: mongoId, await new Promise((resolve) => setTimeout(resolve, 1000));
}) refetch();
.then(() => {
toast.success("Mongo started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mongo");
});
}} }}
> >
<Button <Button
variant="secondary" variant="default"
isLoading={isStarting} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" /> <Rocket className="size-4 mr-1" />
Start Deploy
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>Downloads and sets up the MongoDB database</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> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </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> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}

View File

@@ -21,6 +21,8 @@ interface Props {
} }
export const ShowGeneralMysql = ({ mysqlId }: 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( const { data, refetch } = api.mysql.one.useQuery(
{ {
mysqlId, mysqlId,
@@ -71,153 +73,158 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction {canDeploy && (
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" ? (
<DialogAction <DialogAction
title="Start MySQL" title="Deploy MySQL"
description="Are you sure you want to start this mysql?" description="Are you sure you want to deploy this mysql?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
mysqlId: mysqlId, await new Promise((resolve) => setTimeout(resolve, 1000));
}) refetch();
.then(() => {
toast.success("MySQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting MySQL");
});
}} }}
> >
<Button <Button
variant="secondary" variant="default"
isLoading={isStarting} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" /> <Rocket className="size-4 mr-1" />
Start Deploy
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>Downloads and sets up the MySQL database</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> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </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> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}

View File

@@ -21,6 +21,8 @@ interface Props {
} }
export const ShowGeneralPostgres = ({ postgresId }: 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( const { data, refetch } = api.postgres.one.useQuery(
{ {
postgresId: postgresId, postgresId: postgresId,
@@ -73,153 +75,162 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider disableHoverableContent={false}> <TooltipProvider disableHoverableContent={false}>
<DialogAction {canDeploy && (
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" ? (
<DialogAction <DialogAction
title="Start PostgreSQL" title="Deploy PostgreSQL"
description="Are you sure you want to start this postgres?" description="Are you sure you want to deploy this postgres?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
postgresId: postgresId, await new Promise((resolve) => setTimeout(resolve, 1000));
}) refetch();
.then(() => {
toast.success("PostgreSQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting PostgreSQL");
});
}} }}
> >
<Button <Button
variant="secondary" variant="default"
isLoading={isStarting} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" /> <Rocket className="size-4 mr-1" />
Start Deploy
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>Downloads and sets up the PostgreSQL database</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> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </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> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}

View File

@@ -57,19 +57,13 @@ export const AdvancedEnvironmentSelector = ({
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
// Get current user's permissions // 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 // Check if user can create environments
const canCreateEnvironments = const canCreateEnvironments = !!permissions?.environment.create;
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canCreateEnvironments === true;
// Check if user can delete environments // Check if user can delete environments
const canDeleteEnvironments = const canDeleteEnvironments = !!permissions?.environment.delete;
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canDeleteEnvironments === true;
const haveServices = const haveServices =
selectedEnvironment && selectedEnvironment &&

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ interface Props {
} }
export const ShowGeneralRedis = ({ redisId }: 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( const { data, refetch } = api.redis.one.useQuery(
{ {
redisId, redisId,
@@ -72,153 +74,158 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction {canDeploy && (
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" ? (
<DialogAction <DialogAction
title="Start Redis" title="Deploy Redis"
description="Are you sure you want to start this redis?" description="Are you sure you want to deploy this redis?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
redisId: redisId, await new Promise((resolve) => setTimeout(resolve, 1000));
}) refetch();
.then(() => {
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Redis");
});
}} }}
> >
<Button <Button
variant="secondary" variant="default"
isLoading={isStarting} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center"> <div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" /> <Rocket className="size-4 mr-1" />
Start Deploy
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>Downloads and sets up the Redis database</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> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </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> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isPending, refetch } = api.user.all.useQuery(); const { data, isPending, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation(); const { mutateAsync } = api.user.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const utils = api.useUtils(); const utils = api.useUtils();
const { data: session } = api.user.session.useQuery(); const { data: session } = api.user.session.useQuery();
@@ -89,40 +90,39 @@ export const ShowUsers = () => {
)?.role; )?.role;
// Owner never has "Edit Permissions" (they're absolute owner) // 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 = const canEditPermissions =
member.role !== "owner" && !isStaticAdminOrOwner &&
member.role === "member" &&
member.user.id !== session?.user?.id; member.user.id !== session?.user?.id;
// Can change role based on hierarchy: // Can change role based on hierarchy:
// - Owner: Can change anyone's role (except themselves and other owners) // - 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 // - Owner role is intransferible
const canChangeRole = const canChangeRole =
member.role !== "owner" && member.role !== "owner" &&
member.user.id !== session?.user?.id && member.user.id !== session?.user?.id &&
(currentUserRole === "owner" || (currentUserRole === "owner" ||
(currentUserRole === "admin" && (currentUserRole === "admin" &&
member.role === "member")); member.role !== "admin"));
// Delete/Unlink follow same hierarchy as role changes const canDeleteMember =
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted) permissions?.member.delete ?? false;
// - 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 canUnlink = // Self-hosted: "Delete User" removes the user entirely
// Cloud: "Unlink User" removes from the organization only
const canRemove =
member.role !== "owner" && member.role !== "owner" &&
member.user.id !== session?.user?.id && member.user.id !== session?.user?.id &&
(currentUserRole === "owner" || (currentUserRole === "owner" ||
(currentUserRole === "admin" && (currentUserRole === "admin" &&
member.role === "member")); member.role !== "admin") ||
(canDeleteMember && !isStaticAdminOrOwner));
const canDelete = canRemove && !isCloud;
const canUnlink = canRemove && !!isCloud;
const hasAnyAction = const hasAnyAction =
canEditPermissions || canEditPermissions ||
@@ -134,6 +134,11 @@ export const ShowUsers = () => {
<TableRow key={member.id}> <TableRow key={member.id}>
<TableCell className="w-[100px]"> <TableCell className="w-[100px]">
{member.user.email} {member.user.email}
{member.user.id === session?.user?.id && (
<span className="text-muted-foreground ml-1">
(You)
</span>
)}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge <Badge
@@ -179,9 +184,7 @@ export const ShowUsers = () => {
{canChangeRole && ( {canChangeRole && (
<ChangeRole <ChangeRole
memberId={member.id} memberId={member.id}
currentRole={ currentRole={member.role}
member.role as "admin" | "member"
}
userEmail={member.user.email} userEmail={member.user.email}
/> />
)} )}
@@ -189,6 +192,7 @@ export const ShowUsers = () => {
{canEditPermissions && ( {canEditPermissions && (
<AddUserPermissions <AddUserPermissions
userId={member.user.id} userId={member.user.id}
role={member.role}
/> />
)} )}

View File

@@ -11,6 +11,7 @@ import {
ChevronRight, ChevronRight,
ChevronsUpDown, ChevronsUpDown,
CircleHelp, CircleHelp,
ClipboardList,
Clock, Clock,
CreditCard, CreditCard,
Database, Database,
@@ -92,13 +93,21 @@ import { UserNav } from "./user-nav";
// The types of the queries we are going to use // The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["user"]["get"]; type AuthQueryOutput = inferRouterOutputs<AppRouter>["user"]["get"];
type PermissionsOutput =
inferRouterOutputs<AppRouter>["user"]["getPermissions"];
type EnabledOpts = {
auth?: AuthQueryOutput;
permissions?: PermissionsOutput;
isCloud: boolean;
};
type SingleNavItem = { type SingleNavItem = {
isSingle?: true; isSingle?: true;
title: string; title: string;
url: string; url: string;
icon?: LucideIcon; icon?: LucideIcon;
isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; isEnabled?: (opts: EnabledOpts) => boolean;
}; };
// NavItem type // NavItem type
@@ -112,10 +121,7 @@ type NavItem =
title: string; title: string;
icon: LucideIcon; icon: LucideIcon;
items: SingleNavItem[]; items: SingleNavItem[];
isEnabled?: (opts: { isEnabled?: (opts: EnabledOpts) => boolean;
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
}; };
// ExternalLink type // ExternalLink type
@@ -124,7 +130,7 @@ type ExternalLink = {
name: string; name: string;
url: string; url: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; isEnabled?: (opts: EnabledOpts) => boolean;
}; };
// Menu type // Menu type
@@ -152,14 +158,16 @@ const MENU: Menu = {
title: "Deployments", title: "Deployments",
url: "/dashboard/deployments", url: "/dashboard/deployments",
icon: Rocket, icon: Rocket,
isEnabled: ({ permissions }) => !!permissions?.deployment.read,
}, },
{ {
isSingle: true, isSingle: true,
title: "Monitoring", title: "Monitoring",
url: "/dashboard/monitoring", url: "/dashboard/monitoring",
icon: BarChartHorizontalBigIcon, icon: BarChartHorizontalBigIcon,
// Only enabled in non-cloud environments // Only enabled in non-cloud environments and if user has monitoring.read
isEnabled: ({ isCloud }) => !isCloud, isEnabled: ({ isCloud, permissions }) =>
!isCloud && !!permissions?.monitoring.read,
}, },
{ {
isSingle: true, isSingle: true,
@@ -167,64 +175,44 @@ const MENU: Menu = {
url: "/dashboard/schedules", url: "/dashboard/schedules",
icon: Clock, icon: Clock,
// Only enabled in non-cloud environments // Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) => isEnabled: ({ isCloud, permissions }) =>
!isCloud && (auth?.role === "owner" || auth?.role === "admin"), !isCloud && !!permissions?.organization.update,
}, },
{ {
isSingle: true, isSingle: true,
title: "Traefik File System", title: "Traefik File System",
url: "/dashboard/traefik", url: "/dashboard/traefik",
icon: GalleryVerticalEnd, icon: GalleryVerticalEnd,
// Only enabled for admins and users with access to Traefik files in non-cloud environments // Only enabled for users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) => isEnabled: ({ permissions, isCloud }) =>
!!( !!(permissions?.traefikFiles.read && !isCloud),
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToTraefikFiles) &&
!isCloud
),
}, },
{ {
isSingle: true, isSingle: true,
title: "Docker", title: "Docker",
url: "/dashboard/docker", url: "/dashboard/docker",
icon: BlocksIcon, icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments // Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) => isEnabled: ({ permissions, isCloud }) =>
!!( !!(permissions?.docker.read && !isCloud),
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
}, },
{ {
isSingle: true, isSingle: true,
title: "Swarm", title: "Swarm",
url: "/dashboard/swarm", url: "/dashboard/swarm",
icon: PieChart, icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments // Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) => isEnabled: ({ permissions, isCloud }) =>
!!( !!(permissions?.docker.read && !isCloud),
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
}, },
{ {
isSingle: true, isSingle: true,
title: "Requests", title: "Requests",
url: "/dashboard/requests", url: "/dashboard/requests",
icon: Forward, icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments // Only enabled for users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) => isEnabled: ({ permissions, isCloud }) =>
!!( !!(permissions?.docker.read && !isCloud),
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
}, },
// Legacy unused menu, adjusted to the new structure // Legacy unused menu, adjusted to the new structure
@@ -291,8 +279,8 @@ const MENU: Menu = {
url: "/dashboard/settings/server", url: "/dashboard/settings/server",
icon: Activity, icon: Activity,
// Only enabled for admins in non-cloud environments // Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => isEnabled: ({ permissions, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud), !!(permissions?.organization.update && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
@@ -305,70 +293,59 @@ const MENU: Menu = {
title: "Remote Servers", title: "Remote Servers",
url: "/dashboard/settings/servers", url: "/dashboard/settings/servers",
icon: Server, icon: Server,
// Only enabled for admins isEnabled: ({ permissions }) => !!permissions?.server.read,
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
isSingle: true, isSingle: true,
title: "Users", title: "Users",
icon: Users, icon: Users,
url: "/dashboard/settings/users", url: "/dashboard/settings/users",
// Only enabled for admins // Only enabled for users with member.read permission
isEnabled: ({ auth }) => isEnabled: ({ permissions }) => !!permissions?.member.read,
!!(auth?.role === "owner" || auth?.role === "admin"), },
{
isSingle: true,
title: "Audit Logs",
icon: ClipboardList,
url: "/dashboard/settings/audit-logs",
isEnabled: ({ permissions }) => !!permissions?.auditLog.read,
}, },
{ {
isSingle: true, isSingle: true,
title: "SSH Keys", title: "SSH Keys",
icon: KeyRound, icon: KeyRound,
url: "/dashboard/settings/ssh-keys", url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys // Only enabled for users with access to SSH keys
isEnabled: ({ auth }) => isEnabled: ({ permissions }) => !!permissions?.sshKeys.read,
!!(
auth?.role === "owner" ||
auth?.canAccessToSSHKeys ||
auth?.role === "admin"
),
}, },
{ {
title: "AI", title: "AI",
icon: BotIcon, icon: BotIcon,
url: "/dashboard/settings/ai", url: "/dashboard/settings/ai",
isSingle: true, isSingle: true,
isEnabled: ({ auth }) => isEnabled: ({ permissions }) => !!permissions?.organization.update,
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
isSingle: true, isSingle: true,
title: "Git", title: "Git",
url: "/dashboard/settings/git-providers", url: "/dashboard/settings/git-providers",
icon: GitBranch, icon: GitBranch,
// Only enabled for admins and users with access to Git providers // Only enabled for users with access to Git providers
isEnabled: ({ auth }) => isEnabled: ({ permissions }) => !!permissions?.gitProviders.read,
!!(
auth?.role === "owner" ||
auth?.canAccessToGitProviders ||
auth?.role === "admin"
),
}, },
{ {
isSingle: true, isSingle: true,
title: "Registry", title: "Registry",
url: "/dashboard/settings/registry", url: "/dashboard/settings/registry",
icon: Package, icon: Package,
// Only enabled for admins isEnabled: ({ permissions }) => !!permissions?.registry.read,
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
isSingle: true, isSingle: true,
title: "S3 Destinations", title: "S3 Destinations",
url: "/dashboard/settings/destinations", url: "/dashboard/settings/destinations",
icon: Database, icon: Database,
// Only enabled for admins isEnabled: ({ permissions }) => !!permissions?.destination.read,
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
@@ -376,9 +353,7 @@ const MENU: Menu = {
title: "Certificates", title: "Certificates",
url: "/dashboard/settings/certificates", url: "/dashboard/settings/certificates",
icon: ShieldCheck, icon: ShieldCheck,
// Only enabled for admins isEnabled: ({ permissions }) => !!permissions?.certificate.read,
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -386,24 +361,23 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster", url: "/dashboard/settings/cluster",
icon: Boxes, icon: Boxes,
// Only enabled for admins in non-cloud environments // Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => isEnabled: ({ permissions, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud), !!(permissions?.organization.update && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
title: "Notifications", title: "Notifications",
url: "/dashboard/settings/notifications", url: "/dashboard/settings/notifications",
icon: Bell, icon: Bell,
// Only enabled for admins // Only enabled for users with access to notifications
isEnabled: ({ auth }) => isEnabled: ({ permissions }) => !!permissions?.notification.read,
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
isSingle: true, isSingle: true,
title: "Billing", title: "Billing",
url: "/dashboard/settings/billing", url: "/dashboard/settings/billing",
icon: CreditCard, icon: CreditCard,
// Only enabled for admins in cloud environments // Only enabled for owners in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
}, },
{ {
@@ -411,7 +385,7 @@ const MENU: Menu = {
title: "License", title: "License",
url: "/dashboard/settings/license", url: "/dashboard/settings/license",
icon: Key, icon: Key,
// Only enabled for admins in non-cloud environments // Only enabled for owners
isEnabled: ({ auth }) => !!(auth?.role === "owner"), isEnabled: ({ auth }) => !!(auth?.role === "owner"),
}, },
{ {
@@ -420,8 +394,7 @@ const MENU: Menu = {
url: "/dashboard/settings/sso", url: "/dashboard/settings/sso",
icon: LogIn, icon: LogIn,
// Enabled for admins in both cloud and self-hosted (enterprise) // Enabled for admins in both cloud and self-hosted (enterprise)
isEnabled: ({ auth }) => isEnabled: ({ permissions }) => !!permissions?.organization.update,
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -453,6 +426,7 @@ const MENU: Menu = {
*/ */
function createMenuForAuthUser(opts: { function createMenuForAuthUser(opts: {
auth?: AuthQueryOutput; auth?: AuthQueryOutput;
permissions?: PermissionsOutput;
isCloud: boolean; isCloud: boolean;
whitelabeling?: { whitelabeling?: {
docsUrl?: string | null; docsUrl?: string | null;
@@ -461,7 +435,7 @@ function createMenuForAuthUser(opts: {
}): Menu { }): Menu {
const filterEnabled = < const filterEnabled = <
T extends { T extends {
isEnabled?: (o: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; isEnabled?: (o: EnabledOpts) => boolean;
}, },
>( >(
items: readonly T[], items: readonly T[],
@@ -469,7 +443,11 @@ function createMenuForAuthUser(opts: {
items.filter((item) => items.filter((item) =>
!item.isEnabled !item.isEnabled
? true ? true
: item.isEnabled({ auth: opts.auth, isCloud: opts.isCloud }), : item.isEnabled({
auth: opts.auth,
permissions: opts.permissions,
isCloud: opts.isCloud,
}),
) as T[]; ) as T[];
// Apply whitelabeling URL overrides to help items // Apply whitelabeling URL overrides to help items
@@ -567,6 +545,7 @@ function SidebarLogo() {
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } = const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
api.organization.setDefault.useMutation(); api.organization.setDefault.useMutation();
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const isCollapsed = state === "collapsed" && !isMobile;
const { data: activeOrganization } = api.organization.active.useQuery(); const { data: activeOrganization } = api.organization.active.useQuery();
const { data: invitations, refetch: refetchInvitations } = const { data: invitations, refetch: refetchInvitations } =
@@ -592,9 +571,7 @@ function SidebarLogo() {
<SidebarMenu <SidebarMenu
className={cn( className={cn(
"flex gap-2", "flex gap-2",
state === "collapsed" isCollapsed ? "flex-col" : "flex-row justify-between items-center",
? "flex-col"
: "flex-row justify-between items-center",
)} )}
> >
{/* Organization Logo and Selector */} {/* Organization Logo and Selector */}
@@ -602,17 +579,17 @@ function SidebarLogo() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size={state === "collapsed" ? "sm" : "lg"} size={isCollapsed ? "sm" : "lg"}
className={cn( className={cn(
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", "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", "flex justify-center items-center p-2 h-10 w-10 mx-auto",
)} )}
> >
<div <div
className={cn( className={cn(
"flex items-center gap-2", "flex items-center gap-2",
state === "collapsed" && "justify-center", isCollapsed && "justify-center",
)} )}
> >
<div <div
@@ -624,7 +601,7 @@ function SidebarLogo() {
<Logo <Logo
className={cn( className={cn(
"transition-all", "transition-all",
state === "collapsed" ? "size-4" : "size-5", isCollapsed ? "size-4" : "size-5",
)} )}
logoUrl={activeOrganization?.logo || undefined} logoUrl={activeOrganization?.logo || undefined}
/> />
@@ -632,7 +609,7 @@ function SidebarLogo() {
<div <div
className={cn( className={cn(
"flex flex-col items-start", "flex flex-col items-start",
state === "collapsed" && "hidden", isCollapsed && "hidden",
)} )}
> >
<p className="text-sm font-medium leading-none"> <p className="text-sm font-medium leading-none">
@@ -641,7 +618,7 @@ function SidebarLogo() {
</div> </div>
</div> </div>
<ChevronsUpDown <ChevronsUpDown
className={cn("ml-auto", state === "collapsed" && "hidden")} className={cn("ml-auto", isCollapsed && "hidden")}
/> />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -790,7 +767,7 @@ function SidebarLogo() {
</SidebarMenuItem> </SidebarMenuItem>
{/* Notification Bell */} {/* Notification Bell */}
<SidebarMenuItem className={cn(state === "collapsed" && "mt-2")}> <SidebarMenuItem className={cn(isCollapsed && "mt-2")}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -798,7 +775,7 @@ function SidebarLogo() {
size="icon" size="icon"
className={cn( className={cn(
"relative", "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" /> <Bell className="size-4" />
@@ -894,6 +871,7 @@ export default function Page({ children }: Props) {
const pathname = usePathname(); const pathname = usePathname();
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: whitelabeling } = api.whitelabeling.get.useQuery(undefined, { const { data: whitelabeling } = api.whitelabeling.get.useQuery(undefined, {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@@ -907,7 +885,12 @@ export default function Page({ children }: Props) {
home: filteredHome, home: filteredHome,
settings: filteredSettings, settings: filteredSettings,
help, help,
} = createMenuForAuthUser({ auth, isCloud: !!isCloud, whitelabeling }); } = createMenuForAuthUser({
auth,
permissions,
isCloud: !!isCloud,
whitelabeling,
});
const activeItem = findActiveNavItem( const activeItem = findActiveNavItem(
[...filteredHome, ...filteredSettings], [...filteredHome, ...filteredSettings],
@@ -1147,7 +1130,7 @@ export default function Page({ children }: Props) {
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarMenu className="flex flex-col gap-2"> <SidebarMenu className="flex flex-col gap-2">
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && ( {!isCloud && permissions?.organization.update && (
<SidebarMenuItem> <SidebarMenuItem>
<UpdateServerButton /> <UpdateServerButton />
</SidebarMenuItem> </SidebarMenuItem>
@@ -1161,14 +1144,9 @@ export default function Page({ children }: Props) {
</div> </div>
)} )}
{dokployVersion && ( {dokployVersion && (
<> <div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden"> Version {dokployVersion}
Version {dokployVersion} </div>
</div>
<div className="hidden text-xs text-muted-foreground text-center group-data-[collapsible=icon]:block">
{dokployVersion}
</div>
</>
)} )}
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>

View File

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

View File

@@ -0,0 +1,891 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Loader2, PlusIcon, ShieldCheck, TrashIcon, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { DialogAction } from "@/components/shared/dialog-action";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
/** Labels and descriptions for each resource */
const RESOURCE_META: Record<string, { label: string; description: string }> = {
project: {
label: "Projects",
description: "Manage project creation and deletion",
},
service: {
label: "Services",
description:
"Manage services (applications, databases, compose) within projects",
},
environment: {
label: "Environments",
description: "Manage environment creation, viewing, and deletion",
},
docker: {
label: "Docker",
description: "Access to Docker containers, images, and volumes management",
},
sshKeys: {
label: "SSH Keys",
description: "Manage SSH key configurations for servers and repositories",
},
gitProviders: {
label: "Git Providers",
description: "Access to Git providers (GitHub, GitLab, Bitbucket, Gitea)",
},
traefikFiles: {
label: "Traefik Files",
description: "Access to the Traefik file system configuration",
},
api: {
label: "API / CLI",
description: "Access to API keys and CLI usage",
},
// Enterprise-only resources
volume: {
label: "Volumes",
description: "Manage persistent volumes and mounts attached to services",
},
deployment: {
label: "Deployments",
description: "Trigger, view, and cancel service deployments",
},
envVars: {
label: "Service Env Vars",
description: "View and edit environment variables of services",
},
projectEnvVars: {
label: "Project Shared Env Vars",
description: "View and edit shared environment variables at project level",
},
environmentEnvVars: {
label: "Environment Shared Env Vars",
description:
"View and edit shared environment variables at environment level",
},
server: {
label: "Servers",
description: "Manage remote servers and nodes",
},
registry: {
label: "Registries",
description: "Manage Docker image registries",
},
certificate: {
label: "Certificates",
description: "Manage SSL/TLS certificates",
},
backup: {
label: "Backups",
description: "Manage database backups and restores",
},
volumeBackup: {
label: "Volume Backups",
description: "Manage Docker volume backups and restores",
},
schedule: {
label: "Schedules",
description: "Manage scheduled jobs (commands, deployments, scripts)",
},
domain: {
label: "Domains",
description: "Manage custom domains assigned to services",
},
destination: {
label: "S3 Destinations",
description:
"Manage S3-compatible backup destinations (AWS, Cloudflare R2, etc.)",
},
notification: {
label: "Notifications",
description:
"Manage notification providers (Slack, Discord, Telegram, etc.)",
},
member: {
label: "Users",
description: "Manage organization members, invitations, and roles",
},
logs: {
label: "Logs",
description: "View service and deployment logs",
},
monitoring: {
label: "Monitoring",
description: "View server and service metrics (CPU, RAM, disk)",
},
auditLog: {
label: "Audit Logs",
description: "View the audit log of actions performed in the organization",
},
};
/** Descriptions for each action within a resource */
const ACTION_META: Record<
string,
Record<string, { label: string; description: string }>
> = {
project: {
create: { label: "Create", description: "Create new projects" },
delete: {
label: "Delete",
description: "Delete projects and all their content",
},
},
service: {
create: {
label: "Create",
description: "Create new services inside projects",
},
read: {
label: "Read",
description: "View services, logs, and deployments",
},
delete: {
label: "Delete",
description: "Delete services from projects",
},
},
environment: {
create: {
label: "Create",
description: "Create new environments in projects",
},
read: {
label: "Read",
description: "View environments and their services",
},
delete: {
label: "Delete",
description: "Delete environments and their content",
},
},
docker: {
read: {
label: "Read",
description: "View Docker containers, images, networks, and volumes",
},
},
sshKeys: {
read: {
label: "Read",
description: "View SSH key configurations",
},
create: {
label: "Create",
description: "Create and edit SSH keys",
},
delete: {
label: "Delete",
description: "Remove SSH keys",
},
},
gitProviders: {
read: {
label: "Read",
description: "View Git provider connections",
},
create: {
label: "Create",
description: "Create and update Git provider connections",
},
delete: {
label: "Delete",
description: "Remove Git provider connections",
},
},
traefikFiles: {
read: {
label: "Read",
description: "View Traefik configuration files",
},
write: {
label: "Write",
description: "Edit and save Traefik configuration files",
},
},
api: {
read: {
label: "Read",
description: "Create and manage API keys for CLI access",
},
},
volume: {
read: {
label: "Read",
description: "View volumes and mounts attached to services",
},
create: { label: "Create", description: "Add and edit volumes and mounts" },
delete: {
label: "Delete",
description: "Remove volumes and mounts from services",
},
},
deployment: {
read: { label: "Read", description: "View deployment history and status" },
create: {
label: "Deploy",
description: "Trigger new deployments manually",
},
cancel: { label: "Cancel", description: "Cancel running deployments" },
},
envVars: {
read: { label: "Read", description: "View environment variable values" },
write: {
label: "Write",
description: "Create, update, and delete environment variables",
},
},
projectEnvVars: {
read: {
label: "Read",
description: "View project-level shared environment variables",
},
write: {
label: "Write",
description: "Edit project-level shared environment variables",
},
},
environmentEnvVars: {
read: {
label: "Read",
description: "View environment-level shared environment variables",
},
write: {
label: "Write",
description: "Edit environment-level shared environment variables",
},
},
server: {
read: {
label: "Read",
description: "View server list and connection details",
},
create: { label: "Create", description: "Add new remote servers" },
delete: {
label: "Delete",
description: "Remove servers from the organization",
},
},
registry: {
read: { label: "Read", description: "View configured Docker registries" },
create: { label: "Create", description: "Add new Docker registries" },
delete: { label: "Delete", description: "Remove Docker registries" },
},
certificate: {
read: { label: "Read", description: "View SSL/TLS certificates" },
create: {
label: "Create",
description: "Issue and configure new certificates",
},
delete: { label: "Delete", description: "Remove certificates" },
},
backup: {
read: { label: "Read", description: "View backup history and status" },
create: { label: "Create", description: "Trigger manual backups" },
delete: { label: "Delete", description: "Delete backup files" },
restore: {
label: "Restore",
description: "Restore a database from a backup",
},
},
volumeBackup: {
read: {
label: "Read",
description: "View volume backup history and status",
},
create: {
label: "Create",
description: "Create and trigger volume backups",
},
update: {
label: "Update",
description: "Update volume backup configuration",
},
delete: { label: "Delete", description: "Delete volume backup files" },
restore: {
label: "Restore",
description: "Restore a Docker volume from a backup",
},
},
schedule: {
read: {
label: "Read",
description: "View scheduled jobs and their history",
},
create: { label: "Create", description: "Create and run scheduled jobs" },
update: {
label: "Update",
description: "Update scheduled job configuration",
},
delete: { label: "Delete", description: "Delete scheduled jobs" },
},
domain: {
read: { label: "Read", description: "View domains assigned to services" },
create: { label: "Create", description: "Assign new domains to services" },
delete: { label: "Delete", description: "Remove domains from services" },
},
destination: {
read: { label: "Read", description: "View S3 backup destinations" },
create: { label: "Create", description: "Add and edit S3 destinations" },
delete: { label: "Delete", description: "Remove S3 destinations" },
},
notification: {
read: { label: "Read", description: "View notification providers" },
create: {
label: "Create",
description: "Add and edit notification providers",
},
delete: { label: "Delete", description: "Remove notification providers" },
},
member: {
read: {
label: "Read",
description: "View the list of organization members",
},
create: {
label: "Create",
description: "Invite new members to the organization",
},
update: {
label: "Update",
description: "Change member roles and permissions",
},
delete: {
label: "Delete",
description: "Remove members from the organization",
},
},
logs: {
read: { label: "Read", description: "View real-time and historical logs" },
},
monitoring: {
read: {
label: "Read",
description: "View CPU, RAM, disk, and network metrics",
},
},
auditLog: {
read: { label: "Read", description: "View the audit log history" },
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
const HIDDEN_RESOURCES = ["organization", "invitation", "team", "ac"];
const createRoleSchema = z.object({
roleName: z
.string()
.min(1, "Role name is required")
.max(50, "Role name must be 50 characters or less")
.regex(
/^[a-zA-Z0-9_-]+$/,
"Only letters, numbers, hyphens, and underscores allowed",
),
});
type CreateRoleSchema = z.infer<typeof createRoleSchema>;
export const ManageCustomRoles = () => {
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<ShieldCheck className="size-6 text-muted-foreground self-center" />
Custom Roles
</CardTitle>
<CardDescription>
Create and manage custom roles with fine-grained permissions
</CardDescription>
</CardHeader>
<CardContent className="border-t pt-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Custom Roles",
description:
"Custom roles with fine-grained permissions are part of Dokploy Enterprise. Add a valid license to create and assign custom roles.",
ctaLabel: "Go to License",
}}
>
<CustomRolesContent />
</EnterpriseFeatureGate>
</CardContent>
</div>
</Card>
);
};
interface HandleCustomRoleProps {
roleName?: string;
initialPermissions?: Record<string, string[]>;
onSuccess: () => void;
}
function HandleCustomRole({
roleName,
initialPermissions,
onSuccess,
}: HandleCustomRoleProps) {
const [open, setOpen] = useState(false);
const [permissions, setPermissions] = useState<Record<string, string[]>>({});
const { data: statements } = api.customRole.getStatements.useQuery();
const isEdit = !!roleName;
const form = useForm<CreateRoleSchema>({
defaultValues: { roleName: "" },
resolver: zodResolver(createRoleSchema),
});
useEffect(() => {
if (open) {
setPermissions(initialPermissions ? { ...initialPermissions } : {});
form.reset({ roleName: isEdit ? (roleName ?? "") : "" });
}
}, [open]);
const { mutateAsync: createRole, isPending: isCreating } =
api.customRole.create.useMutation();
const { mutateAsync: updateRole, isPending: isUpdating } =
api.customRole.update.useMutation();
const visibleResources = statements
? Object.entries(statements).filter(
([key]) => !HIDDEN_RESOURCES.includes(key),
)
: [];
const togglePermission = (resource: string, action: string) => {
setPermissions((prev) => {
const current = prev[resource] || [];
const has = current.includes(action);
return {
...prev,
[resource]: has
? current.filter((a) => a !== action)
: [...current, action],
};
});
};
const handleSubmit = async (data: CreateRoleSchema) => {
try {
if (isEdit) {
const newName = data.roleName !== roleName ? data.roleName : undefined;
await updateRole({
roleName: roleName!,
newRoleName: newName,
permissions,
});
toast.success(`Role "${newName ?? roleName}" updated`);
} else {
await createRole({ roleName: data.roleName, permissions });
toast.success(`Role "${data.roleName}" created`);
}
if (!isEdit) {
setOpen(false);
}
onSuccess();
} catch (error) {
let message = `Error ${isEdit ? "updating" : "creating"} role`;
if (error instanceof Error) {
try {
const parsed = JSON.parse(error.message);
if (Array.isArray(parsed) && parsed[0]?.message) {
message = parsed[0].message;
} else {
message = error.message;
}
} catch {
message = error.message;
}
}
toast.error(message);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{isEdit ? (
<Button variant="outline" size="sm" className="h-7 text-xs">
Edit
</Button>
) : (
<Button size="sm">
<PlusIcon className="size-4 mr-1" />
Create Role
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[85vh] sm:max-w-5xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? "Edit Role" : "Create Custom Role"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update permissions for this role"
: "Define a new role with specific permissions"}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="handle-role-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="roleName"
render={({ field }) => (
<FormItem>
<FormLabel>Role Name</FormLabel>
<FormControl>
<Input
placeholder="e.g. developer, viewer, deployer"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<PermissionEditor
resources={visibleResources}
permissions={permissions}
onToggle={togglePermission}
/>
<DialogFooter>
<Button
isLoading={isEdit ? isUpdating : isCreating}
form="handle-role-form"
type="submit"
>
{isEdit ? "Save Changes" : "Create Role"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const CustomRolesContent = () => {
const {
data: customRoles,
isPending,
refetch,
} = api.customRole.all.useQuery();
const { mutateAsync: deleteRole } = api.customRole.remove.useMutation();
const handleDelete = async (roleName: string) => {
try {
await deleteRole({ roleName });
toast.success(`Role "${roleName}" deleted`);
refetch();
} catch (error) {
let message = "Error deleting role";
if (error instanceof Error) {
try {
const parsed = JSON.parse(error.message);
message =
Array.isArray(parsed) && parsed[0]?.message
? parsed[0].message
: error.message;
} catch {
message = error.message;
}
}
toast.error(message);
}
};
if (isPending) {
return (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<HandleCustomRole onSuccess={refetch} />
</div>
{customRoles?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center text-center py-8">
<div className="rounded-full bg-muted p-4">
<ShieldCheck className="size-7 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">No custom roles yet</p>
<p className="text-xs text-muted-foreground">
Create a role to define fine-grained access for your team members.
</p>
</div>
</div>
) : (
<div className="grid gap-3">
{customRoles?.map((role) => {
const totalPermissions = Object.values(role.permissions).flat()
.length;
const enabledResources = Object.entries(role.permissions).filter(
([, actions]) => (actions as string[]).length > 0,
);
return (
<div
key={role.role}
className="rounded-lg border bg-muted/20 p-4 space-y-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div className="rounded-md bg-primary/10 p-1.5 shrink-0">
<ShieldCheck className="size-4 text-primary" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold text-sm truncate">
{role.role}
</p>
{role.memberCount > 0 && (
<MembersBadge
roleName={role.role}
count={role.memberCount}
/>
)}
</div>
<p className="text-xs text-muted-foreground">
{enabledResources.length} resource
{enabledResources.length !== 1 ? "s" : ""} ·{" "}
{totalPermissions} permission
{totalPermissions !== 1 ? "s" : ""}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<HandleCustomRole
roleName={role.role}
initialPermissions={role.permissions}
onSuccess={refetch}
/>
<DialogAction
title="Delete Role"
description={
<div className="space-y-3">
{role.memberCount > 0 && (
<AlertBlock type="error">
<strong>
{role.memberCount} member
{role.memberCount !== 1 ? "s are" : " is"}{" "}
currently assigned
</strong>{" "}
to this role. Reassign them before deleting.
</AlertBlock>
)}
<span>
Are you sure you want to delete the{" "}
<strong>"{role.role}"</strong> role? This action
cannot be undone.
</span>
</div>
}
disabled={role.memberCount > 0}
type="destructive"
onClick={() => handleDelete(role.role)}
>
<Button variant="ghost" size="icon" className="h-7 w-7">
<TrashIcon className="size-3.5 text-red-500" />
</Button>
</DialogAction>
</div>
</div>
{enabledResources.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1 border-t">
{enabledResources.map(([resource, actions]) => (
<div
key={resource}
className="flex items-center gap-1 rounded-md bg-background border px-2 py-1"
>
<span className="text-xs font-medium text-foreground">
{RESOURCE_META[resource]?.label || resource}
</span>
<span className="text-muted-foreground text-xs">·</span>
<span className="text-xs text-muted-foreground">
{(actions as string[])
.map((a) => ACTION_META[resource]?.[a]?.label || a)
.join(", ")}
</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
function MembersBadge({
roleName,
count,
}: {
roleName: string;
count: number;
}) {
const [open, setOpen] = useState(false);
const { data: members, isLoading } = api.customRole.membersByRole.useQuery(
{ roleName },
{ enabled: open },
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/80 transition-colors cursor-pointer"
>
<Users className="size-3" />
{count}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
<p className="text-xs font-medium text-muted-foreground mb-2 px-1">
Assigned members
</p>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : members && members.length > 0 ? (
<ul className="space-y-1">
{members.map((m) => (
<li
key={m.id}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{(m.firstName?.[0] || m.email?.[0] || "?").toUpperCase()}
</div>
<div className="min-w-0">
{(m.firstName || m.lastName) && (
<p className="text-xs font-medium truncate">
{[m.firstName, m.lastName].filter(Boolean).join(" ")}
</p>
)}
<p className="text-xs text-muted-foreground truncate">
{m.email}
</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground px-1 py-2">
No members found.
</p>
)}
</PopoverContent>
</Popover>
);
}
/** Reusable permission toggle grid with descriptions */
function PermissionEditor({
resources,
permissions,
onToggle,
}: {
resources: [string, readonly string[]][];
permissions: Record<string, string[]>;
onToggle: (resource: string, action: string) => void;
}) {
return (
<div className="space-y-3">
<p className="text-sm font-medium">Permissions</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{resources.map(([resource, actions]) => {
const meta = RESOURCE_META[resource];
return (
<div key={resource} className="rounded-lg border p-3 space-y-3">
<div>
<p className="text-sm font-medium">{meta?.label || resource}</p>
{meta?.description && (
<p className="text-xs text-muted-foreground">
{meta.description}
</p>
)}
</div>
<div className="flex flex-col gap-2">
{actions.map((action) => {
const actionMeta = ACTION_META[resource]?.[action];
return (
<div
key={action}
className="flex items-center gap-3 cursor-pointer rounded-md border p-2 hover:bg-muted/50 transition-colors"
onClick={() => onToggle(resource, action)}
>
<Switch
checked={
permissions[resource]?.includes(action) ?? false
}
onCheckedChange={() => onToggle(resource, action)}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">
{actionMeta?.label || action}
</span>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -17,6 +17,8 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarTrigger } from "@/components/ui/sidebar";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
interface BreadcrumbEntry { interface BreadcrumbEntry {
name: string; name: string;
@@ -32,9 +34,11 @@ interface Props {
} }
export const BreadcrumbSidebar = ({ list }: Props) => { export const BreadcrumbSidebar = ({ list }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
return ( 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"> <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"> <div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-2 h-4" />
@@ -75,6 +79,7 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>
{!isCloud && <TimeBadge />}
</div> </div>
</header> </header>
); );

View File

@@ -213,7 +213,9 @@ const Sidebar = React.forwardRef<
} }
side={side} 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> </SheetContent>
</Sheet> </Sheet>
); );
@@ -412,7 +414,7 @@ const SidebarContent = React.forwardRef<
ref={ref} ref={ref}
data-sidebar="content" data-sidebar="content"
className={cn( 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, className,
)} )}
{...props} {...props}

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

View File

@@ -1044,6 +1044,13 @@
"when": 1773129798212, "when": 1773129798212,
"tag": "0148_futuristic_bullseye", "tag": "0148_futuristic_bullseye",
"breakpoints": true "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 { ssoClient } from "@better-auth/sso/client";
import { apiKeyClient } from "@better-auth/api-key/client";
import { import {
adminClient, adminClient,
apiKeyClient,
inferAdditionalFields, inferAdditionalFields,
organizationClient, organizationClient,
twoFactorClient, twoFactorClient,

View File

@@ -46,7 +46,8 @@
"@ai-sdk/mistral": "^3.0.20", "@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/sso": "1.5.0-beta.16", "@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
@@ -99,7 +100,7 @@
"ai": "^6.0.86", "ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0", "ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"better-auth": "1.5.0-beta.16", "better-auth": "1.5.4",
"bl": "6.0.11", "bl": "6.0.11",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"bullmq": "5.67.3", "bullmq": "5.67.3",

View File

@@ -1,4 +1,5 @@
import { validateRequest } from "@dokploy/server/lib/auth"; import { validateRequest } from "@dokploy/server/lib/auth";
import { hasPermission } from "@dokploy/server/services/permission";
import { Rocket } from "lucide-react"; import { Rocket } from "lucide-react";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -79,7 +80,7 @@ DeploymentsPage.getLayout = (page: ReactElement) => {
}; };
export async function getServerSideProps(ctx: GetServerSidePropsContext) { export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { user } = await validateRequest(ctx.req); const { user, session } = await validateRequest(ctx.req);
if (!user) { if (!user) {
return { return {
redirect: { 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 { return {
props: {}, props: {},
}; };

View File

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

View File

@@ -1,5 +1,6 @@
import { IS_CLOUD } from "@dokploy/server/constants"; import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth"; import { validateRequest } from "@dokploy/server/lib/auth";
import { hasPermission } from "@dokploy/server/services/permission";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react"; 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) { if (!user) {
return { return {
redirect: { 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 { return {
props: {}, props: {},
}; };

View File

@@ -272,6 +272,7 @@ const EnvironmentPage = (
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false); const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId, environmentId } = props; const { projectId, environmentId } = props;
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({ const { data: environments } = api.environment.byProjectId.useQuery({
projectId: projectId, projectId: projectId,
@@ -905,9 +906,7 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}> <ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button> <Button variant="outline">Project Environment</Button>
</ProjectEnvironment> </ProjectEnvironment>
{(auth?.role === "owner" || {permissions?.service.create && (
auth?.role === "admin" ||
auth?.canCreateServices) && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button> <Button>
@@ -1029,9 +1028,7 @@ const EnvironmentPage = (
Stop Stop
</Button> </Button>
</DialogAction> </DialogAction>
{(auth?.role === "owner" || {permissions?.service.delete && (
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<> <>
<DialogAction <DialogAction
title="Delete Services" title="Delete Services"
@@ -1624,6 +1621,7 @@ export async function getServerSideProps(
environmentId: params.environmentId, environmentId: params.environmentId,
}); });
} catch (error) { } catch (error) {
console.log(error);
// If user doesn't have access to requested environment, redirect to accessible one // If user doesn't have access to requested environment, redirect to accessible one
const accessibleEnvironments = const accessibleEnvironments =
await helpers.environment.byProjectId.fetch({ await helpers.environment.byProjectId.fetch({
@@ -1643,11 +1641,11 @@ export async function getServerSideProps(
}, },
}; };
} }
// No accessible environments, redirect to home // No accessible environments, redirect to projects
return { return {
redirect: { redirect: {
permanent: false, permanent: false,
destination: "/", destination: "/dashboard/projects",
}, },
}; };
} }
@@ -1663,7 +1661,8 @@ export async function getServerSideProps(
environmentId: params.environmentId, environmentId: params.environmentId,
}, },
}; };
} catch { } catch (error) {
console.log(error);
return { return {
redirect: { redirect: {
permanent: false, permanent: false,

View File

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

View File

@@ -81,6 +81,7 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId }); const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({ const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "", projectId: data?.environment?.projectId || "",
@@ -185,11 +186,11 @@ const Service = (
)} )}
</div> </div>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} /> {permissions?.service.create && (
<UpdateCompose composeId={composeId} />
)}
{(auth?.role === "owner" || {permissions?.service.delete && (
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={composeId} type="compose" /> <DeleteService id={composeId} type="compose" />
)} )}
</div> </div>
@@ -232,22 +233,45 @@ const Service = (
<div className="flex flex-row items-center w-full overflow-auto"> <div className="flex flex-row items-center w-full overflow-auto">
<TabsList className="flex gap-8 max-md:gap-4 justify-start"> <TabsList className="flex gap-8 max-md:gap-4 justify-start">
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> {permissions?.envVars.read && (
<TabsTrigger value="domains">Domains</TabsTrigger> <TabsTrigger value="environment">
<TabsTrigger value="deployments">Deployments</TabsTrigger> Environment
<TabsTrigger value="backups">Backups</TabsTrigger> </TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger> )}
<TabsTrigger value="volumeBackups"> {permissions?.domain.read && (
Volume Backups <TabsTrigger value="domains">Domains</TabsTrigger>
</TabsTrigger> )}
<TabsTrigger value="logs">Logs</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" && ( {data?.sourceType !== "raw" && (
<TabsTrigger value="patches">Patches</TabsTrigger> <TabsTrigger value="patches">Patches</TabsTrigger>
)} )}
{((data?.serverId && isCloud) || !data?.server) && ( {permissions?.monitoring.read &&
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> ((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)} )}
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -256,47 +280,56 @@ const Service = (
<ShowGeneralCompose composeId={composeId} /> <ShowGeneralCompose composeId={composeId} />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="environment"> {permissions?.envVars.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="environment">
<ShowEnvironment id={composeId} type="compose" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowEnvironment id={composeId} type="compose" />
</TabsContent> </div>
<TabsContent value="backups"> </TabsContent>
<div className="flex flex-col gap-4 pt-2.5"> )}
<ShowBackups id={composeId} backupType="compose" /> {permissions?.service.create && (
</div> <TabsContent value="backups">
</TabsContent> <div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={composeId} backupType="compose" />
</div>
</TabsContent>
)}
<TabsContent value="schedules"> {permissions?.schedule.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="schedules">
<ShowSchedules id={composeId} scheduleType="compose" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowSchedules id={composeId} scheduleType="compose" />
</TabsContent> </div>
<TabsContent value="volumeBackups"> </TabsContent>
<div className="flex flex-col gap-4 pt-2.5"> )}
<ShowVolumeBackups {permissions?.volumeBackup.read && (
id={composeId} <TabsContent value="volumeBackups">
type="compose" <div className="flex flex-col gap-4 pt-2.5">
serverId={data?.serverId || ""} <ShowVolumeBackups
/> id={composeId}
</div> type="compose"
</TabsContent> serverId={data?.serverId || ""}
<TabsContent value="monitoring"> />
<div className="pt-2.5"> </div>
<div className="flex flex-col border rounded-lg "> </TabsContent>
{data?.serverId && isCloud ? ( )}
<ComposePaidMonitoring {permissions?.monitoring.read && (
serverId={data?.serverId || ""} <TabsContent value="monitoring">
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`} <div className="pt-2.5">
appName={data?.appName || ""} <div className="flex flex-col border rounded-lg ">
token={ {data?.serverId && isCloud ? (
data?.server?.metricsConfig?.server?.token || "" <ComposePaidMonitoring
} serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"} baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`}
/> appName={data?.appName || ""}
) : ( token={
<> data?.server?.metricsConfig?.server?.token || ""
{/* {monitoring?.enabledFeatures && }
appType={data?.composeType || "docker-compose"}
/>
) : (
<>
{/* {monitoring?.enabledFeatures &&
isCloud && isCloud &&
data?.serverId && ( data?.serverId && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2 m-4"> <div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2 m-4">
@@ -320,53 +353,60 @@ const Service = (
appType={data?.composeType || "docker-compose"} appType={data?.composeType || "docker-compose"}
/> />
) : ( */} ) : ( */}
{/* <div> */} {/* <div> */}
<ComposeFreeMonitoring <ComposeFreeMonitoring
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"} appType={data?.composeType || "docker-compose"}
/> />
{/* </div> */} {/* </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>
</div> </TabsContent>
</TabsContent> )}
<TabsContent value="logs"> {permissions?.deployment.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="deployments" className="w-full pt-2.5">
{data?.composeType === "docker-compose" ? ( <div className="flex flex-col gap-4 border rounded-lg">
<ShowDockerLogsCompose <ShowDeployments
id={composeId}
type="compose"
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
appName={data?.appName || ""} refreshToken={data?.refreshToken || ""}
appType={data?.composeType || "docker-compose"}
/> />
) : ( </div>
<ShowDockerLogsStack </TabsContent>
serverId={data?.serverId || ""} )}
appName={data?.appName || ""}
/>
)}
</div>
</TabsContent>
<TabsContent value="deployments" className="w-full pt-2.5"> {permissions?.domain.read && (
<div className="flex flex-col gap-4 border rounded-lg"> <TabsContent value="domains">
<ShowDeployments <div className="flex flex-col gap-4 pt-2.5">
id={composeId} <ShowDomains id={composeId} type="compose" />
type="compose" </div>
serverId={data?.serverId || ""} </TabsContent>
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>
<TabsContent value="patches" className="w-full"> <TabsContent value="patches" className="w-full">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
@@ -374,14 +414,16 @@ const Service = (
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="advanced"> {permissions?.service.create && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="advanced">
<AddCommandCompose composeId={composeId} /> <div className="flex flex-col gap-4 pt-2.5">
<ShowVolumes id={composeId} type="compose" /> <AddCommandCompose composeId={composeId} />
<ShowImport composeId={composeId} /> <ShowVolumes id={composeId} type="compose" />
<IsolatedDeploymentTab composeId={composeId} /> <ShowImport composeId={composeId} />
</div> <IsolatedDeploymentTab composeId={composeId} />
</TabsContent> </div>
</TabsContent>
)}
</Tabs> </Tabs>
)} )}
</CardContent> </CardContent>

View File

@@ -60,6 +60,7 @@ const Mariadb = (
const [tab, setSab] = useState<TabState>(activeTab); const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId }); const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -159,10 +160,10 @@ const Mariadb = (
)} )}
</div> </div>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} /> {permissions?.service.create && (
{(auth?.role === "owner" || <UpdateMariadb mariadbId={mariadbId} />
auth?.role === "admin" || )}
auth?.canDeleteServices) && ( {permissions?.service.delete && (
<DeleteService id={mariadbId} type="mariadb" /> <DeleteService id={mariadbId} type="mariadb" />
)} )}
</div> </div>
@@ -214,13 +215,24 @@ const Mariadb = (
)} )}
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> {permissions?.envVars.read && (
<TabsTrigger value="logs">Logs</TabsTrigger> <TabsTrigger value="environment">
{((data?.serverId && isCloud) || !data?.server) && ( Environment
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> </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="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> {permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList> </TabsList>
</div> </div>
@@ -231,25 +243,28 @@ const Mariadb = (
<ShowExternalMariadbCredentials mariadbId={mariadbId} /> <ShowExternalMariadbCredentials mariadbId={mariadbId} />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="environment"> {permissions?.envVars.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="environment">
<ShowEnvironment id={mariadbId} type="mariadb" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowEnvironment id={mariadbId} type="mariadb" />
</TabsContent> </div>
<TabsContent value="monitoring"> </TabsContent>
<div className="pt-2.5"> )}
<div className="flex flex-col gap-4 border rounded-lg p-6"> {permissions?.monitoring.read && (
{data?.serverId && isCloud ? ( <TabsContent value="monitoring">
<ContainerPaidMonitoring <div className="pt-2.5">
appName={data?.appName || ""} <div className="flex flex-col gap-4 border rounded-lg p-6">
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`} {data?.serverId && isCloud ? (
token={ <ContainerPaidMonitoring
data?.server?.metricsConfig?.server?.token || "" 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 && ( />
) : (
<>
{/* {monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2"> <div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground"> <Label className="text-muted-foreground">
Change Monitoring Change Monitoring
@@ -271,37 +286,42 @@ const Mariadb = (
/> />
) : ( ) : (
<div> */} <div> */}
<ContainerFreeMonitoring <ContainerFreeMonitoring
appName={data?.appName || ""} appName={data?.appName || ""}
/> />
{/* </div> */} {/* </div> */}
{/* )} */} {/* )} */}
</> </>
)} )}
</div>
</div> </div>
</div> </TabsContent>
</TabsContent> )}
<TabsContent value="logs"> {permissions?.logs.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="logs">
<ShowDockerLogs <div className="flex flex-col gap-4 pt-2.5">
serverId={data?.serverId || ""} <ShowDockerLogs
appName={data?.appName || ""} serverId={data?.serverId || ""}
/> appName={data?.appName || ""}
</div> />
</TabsContent> </div>
</TabsContent>
)}
<TabsContent value="backups"> <TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mariadbId} databaseType="mariadb" /> <ShowBackups id={mariadbId} databaseType="mariadb" />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="advanced"> {permissions?.service.create && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="advanced">
<ShowDatabaseAdvancedSettings <div className="flex flex-col gap-4 pt-2.5">
id={mariadbId} <ShowDatabaseAdvancedSettings
type="mariadb" id={mariadbId}
/> type="mariadb"
</div> />
</TabsContent> </div>
</TabsContent>
)}
</Tabs> </Tabs>
)} )}
</CardContent> </CardContent>

View File

@@ -60,6 +60,7 @@ const Mongo = (
const { data } = api.mongo.one.useQuery({ mongoId }); const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({ const { data: environments } = api.environment.byProjectId.useQuery({
@@ -159,10 +160,10 @@ const Mongo = (
</div> </div>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} /> {permissions?.service.create && (
{(auth?.role === "owner" || <UpdateMongo mongoId={mongoId} />
auth?.role === "admin" || )}
auth?.canDeleteServices) && ( {permissions?.service.delete && (
<DeleteService id={mongoId} type="mongo" /> <DeleteService id={mongoId} type="mongo" />
)} )}
</div> </div>
@@ -214,13 +215,24 @@ const Mongo = (
)} )}
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> {permissions?.envVars.read && (
<TabsTrigger value="logs">Logs</TabsTrigger> <TabsTrigger value="environment">
{((data?.serverId && isCloud) || !data?.server) && ( Environment
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> </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="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> {permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList> </TabsList>
</div> </div>
@@ -231,25 +243,28 @@ const Mongo = (
<ShowExternalMongoCredentials mongoId={mongoId} /> <ShowExternalMongoCredentials mongoId={mongoId} />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="environment"> {permissions?.envVars.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="environment">
<ShowEnvironment id={mongoId} type="mongo" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowEnvironment id={mongoId} type="mongo" />
</TabsContent> </div>
<TabsContent value="monitoring"> </TabsContent>
<div className="pt-2.5"> )}
<div className="flex flex-col gap-4 border rounded-lg p-6"> {permissions?.monitoring.read && (
{data?.serverId && isCloud ? ( <TabsContent value="monitoring">
<ContainerPaidMonitoring <div className="pt-2.5">
appName={data?.appName || ""} <div className="flex flex-col gap-4 border rounded-lg p-6">
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`} {data?.serverId && isCloud ? (
token={ <ContainerPaidMonitoring
data?.server?.metricsConfig?.server?.token || "" 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 && ( />
) : (
<>
{/* {monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2"> <div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground"> <Label className="text-muted-foreground">
Change Monitoring Change Monitoring
@@ -271,24 +286,27 @@ const Mongo = (
/> />
) : ( ) : (
<div> */} <div> */}
<ContainerFreeMonitoring <ContainerFreeMonitoring
appName={data?.appName || ""} appName={data?.appName || ""}
/> />
{/* </div> */} {/* </div> */}
{/* )} */} {/* )} */}
</> </>
)} )}
</div>
</div> </div>
</div> </TabsContent>
</TabsContent> )}
<TabsContent value="logs"> {permissions?.logs.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="logs">
<ShowDockerLogs <div className="flex flex-col gap-4 pt-2.5">
serverId={data?.serverId || ""} <ShowDockerLogs
appName={data?.appName || ""} serverId={data?.serverId || ""}
/> appName={data?.appName || ""}
</div> />
</TabsContent> </div>
</TabsContent>
)}
<TabsContent value="backups"> <TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowBackups <ShowBackups
@@ -298,11 +316,16 @@ const Mongo = (
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="advanced"> {permissions?.service.create && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="advanced">
<ShowDatabaseAdvancedSettings id={mongoId} type="mongo" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowDatabaseAdvancedSettings
</TabsContent> id={mongoId}
type="mongo"
/>
</div>
</TabsContent>
)}
</Tabs> </Tabs>
)} )}
</CardContent> </CardContent>

View File

@@ -59,6 +59,7 @@ const MySql = (
const [tab, setSab] = useState<TabState>(activeTab); const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId }); const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({ const { data: environments } = api.environment.byProjectId.useQuery({
@@ -159,10 +160,10 @@ const MySql = (
</div> </div>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} /> {permissions?.service.create && (
{(auth?.role === "owner" || <UpdateMysql mysqlId={mysqlId} />
auth?.role === "admin" || )}
auth?.canDeleteServices) && ( {permissions?.service.delete && (
<DeleteService id={mysqlId} type="mysql" /> <DeleteService id={mysqlId} type="mysql" />
)} )}
</div> </div>
@@ -214,17 +215,24 @@ const MySql = (
)} )}
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment"> {permissions?.envVars.read && (
Environment <TabsTrigger value="environment">
</TabsTrigger> Environment
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">
Monitoring
</TabsTrigger> </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="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> {permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList> </TabsList>
</div> </div>
@@ -235,40 +243,47 @@ const MySql = (
<ShowExternalMysqlCredentials mysqlId={mysqlId} /> <ShowExternalMysqlCredentials mysqlId={mysqlId} />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="environment" className="w-full"> {permissions?.envVars.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="environment" className="w-full">
<ShowEnvironment id={mysqlId} type="mysql" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowEnvironment id={mysqlId} type="mysql" />
</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 || ""}
/>
</>
)}
</div> </div>
</div> </TabsContent>
</TabsContent> )}
<TabsContent value="logs"> {permissions?.monitoring.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="monitoring">
<ShowDockerLogs <div className="pt-2.5">
serverId={data?.serverId || ""} <div className="flex flex-col gap-4 border rounded-lg p-6">
appName={data?.appName || ""} {data?.serverId && isCloud ? (
/> <ContainerPaidMonitoring
</div> appName={data?.appName || ""}
</TabsContent> 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"> <TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowBackups <ShowBackups
@@ -278,14 +293,16 @@ const MySql = (
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="advanced"> {permissions?.service.create && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="advanced">
<ShowDatabaseAdvancedSettings <div className="flex flex-col gap-4 pt-2.5">
id={mysqlId} <ShowDatabaseAdvancedSettings
type="mysql" id={mysqlId}
/> type="mysql"
</div> />
</TabsContent> </div>
</TabsContent>
)}
</Tabs> </Tabs>
)} )}
</CardContent> </CardContent>

View File

@@ -59,6 +59,7 @@ const Postgresql = (
const [tab, setSab] = useState<TabState>(activeTab); const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId }); const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({ const { data: environments } = api.environment.byProjectId.useQuery({
@@ -158,10 +159,10 @@ const Postgresql = (
</div> </div>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} /> {permissions?.service.create && (
{(auth?.role === "owner" || <UpdatePostgres postgresId={postgresId} />
auth?.role === "admin" || )}
auth?.canDeleteServices) && ( {permissions?.service.delete && (
<DeleteService id={postgresId} type="postgres" /> <DeleteService id={postgresId} type="postgres" />
)} )}
</div> </div>
@@ -215,13 +216,24 @@ const Postgresql = (
)} )}
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> {permissions?.envVars.read && (
<TabsTrigger value="logs">Logs</TabsTrigger> <TabsTrigger value="environment">
{((data?.serverId && isCloud) || !data?.server) && ( Environment
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> </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="backups">Backups</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> {permissions?.service.create && (
<TabsTrigger value="advanced">Advanced</TabsTrigger>
)}
</TabsList> </TabsList>
</div> </div>
@@ -236,44 +248,50 @@ const Postgresql = (
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="environment"> {permissions?.envVars.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="environment">
<ShowEnvironment id={postgresId} type="postgres" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowEnvironment id={postgresId} type="postgres" />
</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 || ""}
/>
</>
)}
</div> </div>
</div> </TabsContent>
</TabsContent> )}
<TabsContent value="logs"> {permissions?.monitoring.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="monitoring">
<ShowDockerLogs <div className="pt-2.5">
serverId={data?.serverId || ""} <div className="flex flex-col gap-4 border rounded-lg p-6">
appName={data?.appName || ""} {data?.serverId && isCloud ? (
/> <ContainerPaidMonitoring
</div> appName={data?.appName || ""}
</TabsContent> 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"> <TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowBackups <ShowBackups
@@ -283,14 +301,16 @@ const Postgresql = (
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="advanced"> {permissions?.service.create && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="advanced">
<ShowDatabaseAdvancedSettings <div className="flex flex-col gap-4 pt-2.5">
id={postgresId} <ShowDatabaseAdvancedSettings
type="postgres" id={postgresId}
/> type="postgres"
</div> />
</TabsContent> </div>
</TabsContent>
)}
</Tabs> </Tabs>
)} )}
</CardContent> </CardContent>

View File

@@ -59,6 +59,7 @@ const Redis = (
const { data } = api.redis.one.useQuery({ redisId }); const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({ const { data: environments } = api.environment.byProjectId.useQuery({
@@ -158,10 +159,10 @@ const Redis = (
</div> </div>
<div className="flex flex-row gap-2 justify-end"> <div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} /> {permissions?.service.create && (
{(auth?.role === "owner" || <UpdateRedis redisId={redisId} />
auth?.role === "admin" || )}
auth?.canDeleteServices) && ( {permissions?.service.delete && (
<DeleteService id={redisId} type="redis" /> <DeleteService id={redisId} type="redis" />
)} )}
</div> </div>
@@ -213,12 +214,23 @@ const Redis = (
)} )}
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> {permissions?.envVars.read && (
<TabsTrigger value="logs">Logs</TabsTrigger> <TabsTrigger value="environment">
{((data?.serverId && isCloud) || !data?.server) && ( Environment
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> </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> </TabsList>
</div> </div>
@@ -229,25 +241,28 @@ const Redis = (
<ShowExternalRedisCredentials redisId={redisId} /> <ShowExternalRedisCredentials redisId={redisId} />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="environment"> {permissions?.envVars.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="environment">
<ShowEnvironment id={redisId} type="redis" /> <div className="flex flex-col gap-4 pt-2.5">
</div> <ShowEnvironment id={redisId} type="redis" />
</TabsContent> </div>
<TabsContent value="monitoring"> </TabsContent>
<div className="pt-2.5"> )}
<div className="flex flex-col gap-4 border rounded-lg p-6"> {permissions?.monitoring.read && (
{data?.serverId && isCloud ? ( <TabsContent value="monitoring">
<ContainerPaidMonitoring <div className="pt-2.5">
appName={data?.appName || ""} <div className="flex flex-col gap-4 border rounded-lg p-6">
baseUrl={`${data?.serverId ? `http://${data?.server?.ipAddress}:${data?.server?.metricsConfig?.server?.port}` : "http://localhost:4500"}`} {data?.serverId && isCloud ? (
token={ <ContainerPaidMonitoring
data?.server?.metricsConfig?.server?.token || "" 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 && ( />
) : (
<>
{/* {monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2"> <div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground"> <Label className="text-muted-foreground">
Change Monitoring Change Monitoring
@@ -269,29 +284,37 @@ const Redis = (
/> />
) : ( ) : (
<div> */} <div> */}
<ContainerFreeMonitoring <ContainerFreeMonitoring
appName={data?.appName || ""} appName={data?.appName || ""}
/> />
{/* </div> */} {/* </div> */}
{/* )} */} {/* )} */}
</> </>
)} )}
</div>
</div> </div>
</div> </TabsContent>
</TabsContent> )}
<TabsContent value="logs"> {permissions?.logs.read && (
<div className="flex flex-col gap-4 pt-2.5"> <TabsContent value="logs">
<ShowDockerLogs <div className="flex flex-col gap-4 pt-2.5">
serverId={data?.serverId || ""} <ShowDockerLogs
appName={data?.appName || ""} serverId={data?.serverId || ""}
/> appName={data?.appName || ""}
</div> />
</TabsContent> </div>
<TabsContent value="advanced"> </TabsContent>
<div className="flex flex-col gap-4 pt-2.5"> )}
<ShowDatabaseAdvancedSettings id={redisId} type="redis" /> {permissions?.service.create && (
</div> <TabsContent value="advanced">
</TabsContent> <div className="flex flex-col gap-4 pt-2.5">
<ShowDatabaseAdvancedSettings
id={redisId}
type="redis"
/>
</div>
</TabsContent>
)}
</Tabs> </Tabs>
)} )}
</CardContent> </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 { try {
await helpers.project.all.prefetch(); await helpers.project.all.prefetch();
await helpers.settings.isCloud.prefetch(); await helpers.settings.isCloud.prefetch();
if (user.role === "member") { const userPermissions = await helpers.user.getPermissions.fetch();
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToGitProviders) { if (!userPermissions?.gitProviders.read) {
return { return {
redirect: { redirect: {
permanent: true, permanent: true,
destination: "/", destination: "/",
}, },
}; };
}
} }
return { return {
props: { props: {

View File

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

View File

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

View File

@@ -3,16 +3,24 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import superjson from "superjson"; 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 { ShowInvitations } from "@/components/dashboard/settings/users/show-invitations";
import { ShowUsers } from "@/components/dashboard/settings/users/show-users"; import { ShowUsers } from "@/components/dashboard/settings/users/show-users";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const Page = () => { 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 ( return (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<ShowUsers /> <ShowUsers />
<ShowInvitations /> {canCreateMembers && <ShowInvitations />}
{isOwnerOrAdmin && <ManageCustomRoles />}
</div> </div>
); );
}; };
@@ -28,7 +36,7 @@ export async function getServerSideProps(
const { req, res } = ctx; const { req, res } = ctx;
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
if (!user || user.role === "member") { if (!user) {
return { return {
redirect: { redirect: {
permanent: true, permanent: true,
@@ -48,12 +56,30 @@ export async function getServerSideProps(
}, },
transformer: superjson, transformer: superjson,
}); });
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return { try {
props: { await helpers.user.get.prefetch();
trpcState: helpers.dehydrate(), 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

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

View File

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

View File

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

View File

@@ -23,6 +23,8 @@ import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification"; import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization"; import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch"; 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 { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso"; import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling"; import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
@@ -89,6 +91,8 @@ export const appRouter = createTRPCRouter({
licenseKey: licenseKeyRouter, licenseKey: licenseKeyRouter,
sso: ssoRouter, sso: ssoRouter,
whitelabeling: whitelabelingRouter, whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,
schedule: scheduleRouter, schedule: scheduleRouter,
rollback: rollbackRouter, rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter, volumeBackups: volumeBackupsRouter,

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,12 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; 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 { import {
apiBitbucketTestConnection, apiBitbucketTestConnection,
apiCreateBitbucket, apiCreateBitbucket,
@@ -18,15 +23,23 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const bitbucketRouter = createTRPCRouter({ export const bitbucketRouter = createTRPCRouter({
create: protectedProcedure create: withPermission("gitProviders", "create")
.input(apiCreateBitbucket) .input(apiCreateBitbucket)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createBitbucket( const result = await createBitbucket(
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
ctx.session.userId, ctx.session.userId,
); );
await audit(ctx, {
action: "create",
resourceType: "gitProvider",
resourceName: input.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -37,19 +50,8 @@ export const bitbucketRouter = createTRPCRouter({
}), }),
one: protectedProcedure one: protectedProcedure
.input(apiFindOneBitbucket) .input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId); return 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;
}), }),
bitbucketProviders: protectedProcedure.query(async ({ ctx }) => { bitbucketProviders: protectedProcedure.query(async ({ ctx }) => {
let result = await db.query.bitbucket.findMany({ let result = await db.query.bitbucket.findMany({
@@ -73,53 +75,18 @@ export const bitbucketRouter = createTRPCRouter({
getBitbucketRepositories: protectedProcedure getBitbucketRepositories: protectedProcedure
.input(apiFindOneBitbucket) .input(apiFindOneBitbucket)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
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 getBitbucketRepositories(input.bitbucketId); return await getBitbucketRepositories(input.bitbucketId);
}), }),
getBitbucketBranches: protectedProcedure getBitbucketBranches: protectedProcedure
.input(apiFindBitbucketBranches) .input(apiFindBitbucketBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
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 getBitbucketBranches(input); return await getBitbucketBranches(input);
}), }),
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiBitbucketTestConnection) .input(apiBitbucketTestConnection)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
try { 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); const result = await testBitbucketConnection(input);
return `Found ${result} repositories`; return `Found ${result} repositories`;
@@ -130,23 +97,21 @@ export const bitbucketRouter = createTRPCRouter({
}); });
} }
}), }),
update: protectedProcedure update: withPermission("gitProviders", "create")
.input(apiUpdateBitbucket) .input(apiUpdateBitbucket)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const bitbucketProvider = await findBitbucketById(input.bitbucketId); const result = await updateBitbucket(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, {
...input, ...input,
organizationId: ctx.session.activeOrganizationId, 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 { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; 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 { import {
apiCreateCertificate, apiCreateCertificate,
apiFindCertificate, apiFindCertificate,
@@ -15,7 +16,7 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const certificateRouter = createTRPCRouter({ export const certificateRouter = createTRPCRouter({
create: adminProcedure create: withPermission("certificate", "create")
.input(apiCreateCertificate) .input(apiCreateCertificate)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {
@@ -24,10 +25,20 @@ export const certificateRouter = createTRPCRouter({
message: "Please set a server to create a certificate", 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) .input(apiFindCertificate)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId); const certificates = await findCertificateById(input.certificateId);
@@ -39,7 +50,7 @@ export const certificateRouter = createTRPCRouter({
} }
return certificates; return certificates;
}), }),
remove: adminProcedure remove: withPermission("certificate", "delete")
.input(apiFindCertificate) .input(apiFindCertificate)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const certificates = await findCertificateById(input.certificateId); const certificates = await findCertificateById(input.certificateId);
@@ -49,10 +60,16 @@ export const certificateRouter = createTRPCRouter({
message: "You are not allowed to delete this certificate", 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); await removeCertificateById(input.certificateId);
return true; return true;
}), }),
all: adminProcedure.query(async ({ ctx }) => { all: withPermission("certificate", "read").query(async ({ ctx }) => {
return await db.query.certificates.findMany({ return await db.query.certificates.findMany({
where: eq(certificates.organizationId, ctx.session.activeOrganizationId), where: eq(certificates.organizationId, ctx.session.activeOrganizationId),
}); });

View File

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

View File

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

View File

@@ -5,20 +5,21 @@ import {
findAllDeploymentsByComposeId, findAllDeploymentsByComposeId,
findAllDeploymentsByServerId, findAllDeploymentsByServerId,
findAllDeploymentsCentralized, findAllDeploymentsCentralized,
findApplicationById,
findComposeById,
findDeploymentById, findDeploymentById,
findMemberById,
findServerById,
IS_CLOUD, IS_CLOUD,
removeDeployment, removeDeployment,
resolveServicePath, resolveServicePath,
updateDeploymentStatus, updateDeploymentStatus,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import {
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { import {
apiFindAllByApplication, apiFindAllByApplication,
apiFindAllByCompose, apiFindAllByCompose,
@@ -29,65 +30,46 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
import { myQueue } from "@/server/queues/queueSetup"; import { myQueue } from "@/server/queues/queueSetup";
import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy"; import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const deploymentRouter = createTRPCRouter({ export const deploymentRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
.input(apiFindAllByApplication) .input(apiFindAllByApplication)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); await checkServicePermissionAndAccess(ctx, input.applicationId, {
if ( deployment: ["read"],
application.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findAllDeploymentsByApplicationId(input.applicationId); return await findAllDeploymentsByApplicationId(input.applicationId);
}), }),
allByCompose: protectedProcedure allByCompose: protectedProcedure
.input(apiFindAllByCompose) .input(apiFindAllByCompose)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId); await checkServicePermissionAndAccess(ctx, input.composeId, {
if ( deployment: ["read"],
compose.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await findAllDeploymentsByComposeId(input.composeId); return await findAllDeploymentsByComposeId(input.composeId);
}), }),
allByServer: protectedProcedure allByServer: withPermission("deployment", "read")
.input(apiFindAllByServer) .input(apiFindAllByServer)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
return await findAllDeploymentsByServerId(input.serverId); return await findAllDeploymentsByServerId(input.serverId);
}), }),
allCentralized: protectedProcedure.query(async ({ ctx }) => { allCentralized: withPermission("deployment", "read").query(
const orgId = ctx.session.activeOrganizationId; async ({ ctx }) => {
const accessedServices = const orgId = ctx.session.activeOrganizationId;
ctx.user.role === "member" const accessedServices =
? (await findMemberById(ctx.user.id, orgId)).accessedServices ctx.user.role !== "owner" && ctx.user.role !== "admin"
: null; ? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices
if (accessedServices !== null && accessedServices.length === 0) { : null;
return []; if (accessedServices !== null && accessedServices.length === 0) {
} return [];
return findAllDeploymentsCentralized(orgId, accessedServices); }
}), return findAllDeploymentsCentralized(orgId, accessedServices);
},
),
queueList: protectedProcedure.query(async ({ ctx }) => { queueList: withPermission("deployment", "read").query(async ({ ctx }) => {
const orgId = ctx.session.activeOrganizationId; const orgId = ctx.session.activeOrganizationId;
let rows: QueueJobRow[]; let rows: QueueJobRow[];
@@ -135,7 +117,10 @@ export const deploymentRouter = createTRPCRouter({
allByType: protectedProcedure allByType: protectedProcedure
.input(apiFindAllByType) .input(apiFindAllByType)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
deployment: ["read"],
});
const deploymentsList = await db.query.deployments.findMany({ const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments[`${input.type}Id`], input.id), where: eq(deployments[`${input.type}Id`], input.id),
orderBy: desc(deployments.createdAt), orderBy: desc(deployments.createdAt),
@@ -151,8 +136,14 @@ export const deploymentRouter = createTRPCRouter({
deploymentId: z.string().min(1), deploymentId: z.string().min(1),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const deployment = await findDeploymentById(input.deploymentId); const deployment = await findDeploymentById(input.deploymentId);
const serviceId = deployment.applicationId || deployment.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
}
if (!deployment.pid) { if (!deployment.pid) {
throw new TRPCError({ throw new TRPCError({
@@ -169,6 +160,11 @@ export const deploymentRouter = createTRPCRouter({
} }
await updateDeploymentStatus(deployment.deploymentId, "error"); await updateDeploymentStatus(deployment.deploymentId, "error");
await audit(ctx, {
action: "cancel",
resourceType: "deployment",
resourceId: deployment.deploymentId,
});
}), }),
removeDeployment: protectedProcedure removeDeployment: protectedProcedure
@@ -177,7 +173,20 @@ export const deploymentRouter = createTRPCRouter({
deploymentId: z.string().min(1), deploymentId: z.string().min(1),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
return await removeDeployment(input.deploymentId); const deployment = await findDeploymentById(input.deploymentId);
const serviceId = deployment.applicationId || deployment.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["cancel"],
});
}
const result = await removeDeployment(input.deploymentId);
await audit(ctx, {
action: "delete",
resourceType: "deployment",
resourceId: deployment.deploymentId,
});
return result;
}), }),
}); });

View File

@@ -10,11 +10,8 @@ import {
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { import { createTRPCRouter, withPermission } from "@/server/api/trpc";
adminProcedure, import { audit } from "@/server/api/utils/audit";
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { import {
apiCreateDestination, apiCreateDestination,
apiFindOneDestination, apiFindOneDestination,
@@ -24,14 +21,21 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const destinationRouter = createTRPCRouter({ export const destinationRouter = createTRPCRouter({
create: adminProcedure create: withPermission("destination", "create")
.input(apiCreateDestination) .input(apiCreateDestination)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createDestintation( const result = await createDestintation(
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
await audit(ctx, {
action: "create",
resourceType: "destination",
resourceId: result.destinationId,
resourceName: input.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -40,7 +44,7 @@ export const destinationRouter = createTRPCRouter({
}); });
} }
}), }),
testConnection: adminProcedure testConnection: withPermission("destination", "create")
.input(apiCreateDestination) .input(apiCreateDestination)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { secretAccessKey, bucket, region, endpoint, accessKey, provider } = const { secretAccessKey, bucket, region, endpoint, accessKey, provider } =
@@ -87,7 +91,7 @@ export const destinationRouter = createTRPCRouter({
}); });
} }
}), }),
one: protectedProcedure one: withPermission("destination", "read")
.input(apiFindOneDestination) .input(apiFindOneDestination)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const destination = await findDestinationById(input.destinationId); const destination = await findDestinationById(input.destinationId);
@@ -99,13 +103,13 @@ export const destinationRouter = createTRPCRouter({
} }
return destination; return destination;
}), }),
all: protectedProcedure.query(async ({ ctx }) => { all: withPermission("destination", "read").query(async ({ ctx }) => {
return await db.query.destinations.findMany({ return await db.query.destinations.findMany({
where: eq(destinations.organizationId, ctx.session.activeOrganizationId), where: eq(destinations.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(destinations.createdAt)], orderBy: [desc(destinations.createdAt)],
}); });
}), }),
remove: adminProcedure remove: withPermission("destination", "delete")
.input(apiRemoveDestination) .input(apiRemoveDestination)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -117,15 +121,22 @@ export const destinationRouter = createTRPCRouter({
message: "You are not allowed to delete this destination", message: "You are not allowed to delete this destination",
}); });
} }
return await removeDestinationById( const result = await removeDestinationById(
input.destinationId, input.destinationId,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
await audit(ctx, {
action: "delete",
resourceType: "destination",
resourceId: input.destinationId,
resourceName: destination.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
update: adminProcedure update: withPermission("destination", "create")
.input(apiUpdateDestination) .input(apiUpdateDestination)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -136,10 +147,17 @@ export const destinationRouter = createTRPCRouter({
message: "You are not allowed to update this destination", message: "You are not allowed to update this destination",
}); });
} }
return await updateDestinationById(input.destinationId, { const result = await updateDestinationById(input.destinationId, {
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "destination",
resourceId: input.destinationId,
resourceName: input.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@@ -10,12 +10,13 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { audit } from "@/server/api/utils/audit";
import { createTRPCRouter, withPermission } from "../trpc";
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/; export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
export const dockerRouter = createTRPCRouter({ export const dockerRouter = createTRPCRouter({
getContainers: protectedProcedure getContainers: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
serverId: z.string().optional(), serverId: z.string().optional(),
@@ -31,7 +32,7 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId); return await getContainers(input.serverId);
}), }),
restartContainer: protectedProcedure restartContainer: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
containerId: z containerId: z
@@ -40,11 +41,18 @@ export const dockerRouter = createTRPCRouter({
.regex(containerIdRegex, "Invalid container id."), .regex(containerIdRegex, "Invalid container id."),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
return await containerRestart(input.containerId); const result = await containerRestart(input.containerId);
await audit(ctx, {
action: "start",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
return result;
}), }),
getConfig: protectedProcedure getConfig: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
containerId: z containerId: z
@@ -64,7 +72,7 @@ export const dockerRouter = createTRPCRouter({
return await getConfig(input.containerId, input.serverId); return await getConfig(input.containerId, input.serverId);
}), }),
getContainersByAppNameMatch: protectedProcedure getContainersByAppNameMatch: withPermission("service", "read")
.input( .input(
z.object({ z.object({
appType: z.enum(["stack", "docker-compose"]).optional(), appType: z.enum(["stack", "docker-compose"]).optional(),
@@ -86,7 +94,7 @@ export const dockerRouter = createTRPCRouter({
); );
}), }),
getContainersByAppLabel: protectedProcedure getContainersByAppLabel: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."), appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -108,7 +116,7 @@ export const dockerRouter = createTRPCRouter({
); );
}), }),
getStackContainersByAppName: protectedProcedure getStackContainersByAppName: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."), appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),
@@ -125,7 +133,7 @@ export const dockerRouter = createTRPCRouter({
return await getStackContainersByAppName(input.appName, input.serverId); return await getStackContainersByAppName(input.appName, input.serverId);
}), }),
getServiceContainersByAppName: protectedProcedure getServiceContainersByAppName: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."), appName: z.string().min(1).regex(containerIdRegex, "Invalid app name."),

View File

@@ -1,7 +1,6 @@
import { import {
createDomain, createDomain,
findApplicationById, findApplicationById,
findComposeById,
findDomainById, findDomainById,
findDomainsByApplicationId, findDomainsByApplicationId,
findDomainsByComposeId, findDomainsByComposeId,
@@ -15,9 +14,15 @@ import {
updateDomainById, updateDomainById,
validateDomain, validateDomain,
} from "@dokploy/server"; } from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; 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 { import {
apiCreateDomain, apiCreateDomain,
apiFindCompose, apiFindCompose,
@@ -32,29 +37,22 @@ export const domainRouter = createTRPCRouter({
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
if (input.domainType === "compose" && input.composeId) { if (input.domainType === "compose" && input.composeId) {
const compose = await findComposeById(input.composeId); await checkServicePermissionAndAccess(ctx, input.composeId, {
if ( domain: ["create"],
compose.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
} else if (input.domainType === "application" && input.applicationId) { } else if (input.domainType === "application" && input.applicationId) {
const application = await findApplicationById(input.applicationId); await checkServicePermissionAndAccess(ctx, input.applicationId, {
if ( domain: ["create"],
application.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} }
return await createDomain(input); const domain = await createDomain(input);
await audit(ctx, {
action: "create",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
return domain;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -69,34 +67,20 @@ export const domainRouter = createTRPCRouter({
byApplicationId: protectedProcedure byApplicationId: protectedProcedure
.input(apiFindOneApplication) .input(apiFindOneApplication)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); await checkServicePermissionAndAccess(ctx, input.applicationId, {
if ( domain: ["read"],
application.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findDomainsByApplicationId(input.applicationId); return await findDomainsByApplicationId(input.applicationId);
}), }),
byComposeId: protectedProcedure byComposeId: protectedProcedure
.input(apiFindCompose) .input(apiFindCompose)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId); await checkServicePermissionAndAccess(ctx, input.composeId, {
if ( domain: ["read"],
compose.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await findDomainsByComposeId(input.composeId); return await findDomainsByComposeId(input.composeId);
}), }),
generateDomain: protectedProcedure generateDomain: withPermission("domain", "create")
.input(z.object({ appName: z.string(), serverId: z.string().optional() })) .input(z.object({ appName: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
return generateTraefikMeDomain( return generateTraefikMeDomain(
@@ -105,7 +89,7 @@ export const domainRouter = createTRPCRouter({
input.serverId, input.serverId,
); );
}), }),
canGenerateTraefikMeDomains: protectedProcedure canGenerateTraefikMeDomains: withPermission("domain", "read")
.input(z.object({ serverId: z.string() })) .input(z.object({ serverId: z.string() }))
.query(async ({ input }) => { .query(async ({ input }) => {
if (input.serverId) { if (input.serverId) {
@@ -120,45 +104,28 @@ export const domainRouter = createTRPCRouter({
.input(apiUpdateDomain) .input(apiUpdateDomain)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const currentDomain = await findDomainById(input.domainId); const currentDomain = await findDomainById(input.domainId);
const serviceId = currentDomain.applicationId || currentDomain.composeId;
if (currentDomain.applicationId) { if (serviceId) {
const newApp = await findApplicationById(currentDomain.applicationId); await checkServicePermissionAndAccess(ctx, serviceId, {
if ( domain: ["create"],
newApp.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (currentDomain.composeId) {
const newCompose = await findComposeById(currentDomain.composeId);
if (
newCompose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
} else if (currentDomain.previewDeploymentId) { } else if (currentDomain.previewDeploymentId) {
const newPreviewDeployment = await findPreviewDeploymentById( const preview = await findPreviewDeploymentById(
currentDomain.previewDeploymentId, currentDomain.previewDeploymentId,
); );
if ( await checkServicePermissionAndAccess(ctx, preview.applicationId, {
newPreviewDeployment.application.environment.project domain: ["create"],
.organizationId !== ctx.session.activeOrganizationId });
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this preview deployment",
});
}
} }
const result = await updateDomainById(input.domainId, input); const result = await updateDomainById(input.domainId, input);
const domain = await findDomainById(input.domainId); const domain = await findDomainById(input.domainId);
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
if (domain.applicationId) { if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId); const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain); await manageDomain(application, domain);
@@ -176,59 +143,46 @@ export const domainRouter = createTRPCRouter({
}), }),
one: protectedProcedure.input(apiFindDomain).query(async ({ input, ctx }) => { one: protectedProcedure.input(apiFindDomain).query(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId); const domain = await findDomainById(input.domainId);
if (domain.applicationId) { const serviceId = domain.applicationId || domain.composeId;
const application = await findApplicationById(domain.applicationId); if (serviceId) {
if ( await checkServicePermissionAndAccess(ctx, serviceId, {
application.environment.project.organizationId !== domain: ["read"],
ctx.session.activeOrganizationId });
) { } else if (domain.previewDeploymentId) {
throw new TRPCError({ const preview = await findPreviewDeploymentById(
code: "UNAUTHORIZED", domain.previewDeploymentId,
message: "You are not authorized to access this application", );
}); await checkServicePermissionAndAccess(ctx, preview.applicationId, {
} domain: ["read"],
} else if (domain.composeId) { });
const compose = await findComposeById(domain.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
} }
return await findDomainById(input.domainId); return domain;
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(apiFindDomain) .input(apiFindDomain)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId); const domain = await findDomainById(input.domainId);
if (domain.applicationId) { const serviceId = domain.applicationId || domain.composeId;
const application = await findApplicationById(domain.applicationId); if (serviceId) {
if ( await checkServicePermissionAndAccess(ctx, serviceId, {
application.environment.project.organizationId !== domain: ["delete"],
ctx.session.activeOrganizationId });
) { } else if (domain.previewDeploymentId) {
throw new TRPCError({ const preview = await findPreviewDeploymentById(
code: "UNAUTHORIZED", domain.previewDeploymentId,
message: "You are not authorized to access this application", );
}); await checkServicePermissionAndAccess(ctx, preview.applicationId, {
} domain: ["delete"],
} else if (domain.composeId) { });
const compose = await findComposeById(domain.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
} }
const result = await removeDomainById(input.domainId); const result = await removeDomainById(input.domainId);
await audit(ctx, {
action: "delete",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
if (domain.applicationId) { if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId); const application = await findApplicationById(domain.applicationId);
@@ -238,7 +192,7 @@ export const domainRouter = createTRPCRouter({
return result; return result;
}), }),
validateDomain: protectedProcedure validateDomain: withPermission("domain", "read")
.input( .input(
z.object({ z.object({
domain: z.string(), domain: z.string(),

View File

@@ -1,31 +1,35 @@
import { import {
addNewEnvironment,
checkEnvironmentAccess,
checkEnvironmentCreationPermission,
checkEnvironmentDeletionPermission,
createEnvironment, createEnvironment,
deleteEnvironment, deleteEnvironment,
duplicateEnvironment, duplicateEnvironment,
findEnvironmentById, findEnvironmentById,
findEnvironmentsByProjectId, findEnvironmentsByProjectId,
findMemberById,
updateEnvironmentById, updateEnvironmentById,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import {
addNewEnvironment,
checkEnvironmentAccess,
checkEnvironmentCreationPermission,
checkEnvironmentDeletionPermission,
checkPermission,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { import {
apiCreateEnvironment, apiCreateEnvironment,
apiDuplicateEnvironment, apiDuplicateEnvironment,
apiFindOneEnvironment, apiFindOneEnvironment,
apiRemoveEnvironment, apiRemoveEnvironment,
apiUpdateEnvironment, apiUpdateEnvironment,
environments,
projects,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { environments, projects } from "@/server/db/schema";
// Helper function to filter services within an environment based on user permissions
const filterEnvironmentServices = ( const filterEnvironmentServices = (
environment: any, environment: any,
accessedServices: string[], accessedServices: string[],
@@ -59,12 +63,7 @@ export const environmentRouter = createTRPCRouter({
.input(apiCreateEnvironment) .input(apiCreateEnvironment)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
// Check if user has permission to create environments await checkEnvironmentCreationPermission(ctx, input.projectId);
await checkEnvironmentCreationPermission(
ctx.user.id,
input.projectId,
ctx.session.activeOrganizationId,
);
if (input.name === "production") { if (input.name === "production") {
throw new TRPCError({ throw new TRPCError({
@@ -74,16 +73,15 @@ export const environmentRouter = createTRPCRouter({
}); });
} }
// Allow users to create environments with any name, including "production"
const environment = await createEnvironment(input); const environment = await createEnvironment(input);
if (ctx.user.role === "member") { await addNewEnvironment(ctx, environment.environmentId);
await addNewEnvironment( await audit(ctx, {
ctx.user.id, action: "create",
environment.environmentId, resourceType: "environment",
ctx.session.activeOrganizationId, resourceId: environment.environmentId,
); resourceName: environment.name,
} });
return environment; return environment;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
@@ -100,54 +98,39 @@ export const environmentRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure
.input(apiFindOneEnvironment) .input(apiFindOneEnvironment)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
try { const environment = await findEnvironmentById(input.environmentId);
if (ctx.user.role === "member") { if (
await checkEnvironmentAccess( environment.project.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to access this environment",
});
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedEnvironments, accessedServices } =
await findMemberByUserId(
ctx.user.id, ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
"access",
); );
}
const environment = await findEnvironmentById(input.environmentId); if (!accessedEnvironments.includes(environment.environmentId)) {
if (
environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "You are not allowed to access this environment", message: "You are not allowed to access this environment",
}); });
} }
// Check environment access and filter services for members const filteredEnvironment = filterEnvironmentServices(
if (ctx.user.role === "member") { environment,
const { accessedEnvironments, accessedServices } = accessedServices,
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId); );
if (!accessedEnvironments.includes(environment.environmentId)) { return filteredEnvironment;
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to access this environment",
});
}
// Filter services based on member permissions
const filteredEnvironment = filterEnvironmentServices(
environment,
accessedServices,
);
return filteredEnvironment;
}
return environment;
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Environment not found",
});
} }
return environment;
}), }),
byProjectId: protectedProcedure byProjectId: protectedProcedure
@@ -156,7 +139,6 @@ export const environmentRouter = createTRPCRouter({
try { try {
const environments = await findEnvironmentsByProjectId(input.projectId); const environments = await findEnvironmentsByProjectId(input.projectId);
// Check organization access
if ( if (
environments.some( environments.some(
(environment) => (environment) =>
@@ -170,12 +152,13 @@ export const environmentRouter = createTRPCRouter({
}); });
} }
// Filter environments for members based on their permissions if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
if (ctx.user.role === "member") {
const { accessedEnvironments, accessedServices } = const { accessedEnvironments, accessedServices } =
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId); await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Filter environments to only show those the member has access to
const filteredEnvironments = environments const filteredEnvironments = environments
.filter((environment) => .filter((environment) =>
accessedEnvironments.includes(environment.environmentId), accessedEnvironments.includes(environment.environmentId),
@@ -211,7 +194,6 @@ export const environmentRouter = createTRPCRouter({
}); });
} }
// Prevent deletion of the default environment
if (environment.isDefault) { if (environment.isDefault) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -219,24 +201,17 @@ export const environmentRouter = createTRPCRouter({
}); });
} }
// Check environment deletion permission await checkEnvironmentDeletionPermission(ctx, environment.projectId);
await checkEnvironmentDeletionPermission(
ctx.user.id,
environment.projectId,
ctx.session.activeOrganizationId,
);
// Additional check for environment access for members await checkEnvironmentAccess(ctx, input.environmentId, "read");
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
const deletedEnvironment = await deleteEnvironment(input.environmentId); const deletedEnvironment = await deleteEnvironment(input.environmentId);
await audit(ctx, {
action: "delete",
resourceType: "environment",
resourceId: deletedEnvironment?.environmentId,
resourceName: deletedEnvironment?.name,
});
return deletedEnvironment; return deletedEnvironment;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
@@ -256,18 +231,14 @@ export const environmentRouter = createTRPCRouter({
try { try {
const { environmentId, ...updateData } = input; const { environmentId, ...updateData } = input;
// Allow users to rename environments to any name, including "production" await checkEnvironmentAccess(ctx, environmentId, "read");
if (ctx.user.role === "member") {
await checkEnvironmentAccess( if (updateData.env !== undefined) {
ctx.user.id, await checkPermission(ctx, { environmentEnvVars: ["write"] });
environmentId,
ctx.session.activeOrganizationId,
"access",
);
} }
const currentEnvironment = await findEnvironmentById(environmentId); const currentEnvironment = await findEnvironmentById(environmentId);
// Prevent renaming the default environment, but allow updating env and description
if (currentEnvironment.isDefault && updateData.name !== undefined) { if (currentEnvironment.isDefault && updateData.name !== undefined) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -284,9 +255,8 @@ export const environmentRouter = createTRPCRouter({
}); });
} }
// Check environment access for members if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
if (ctx.user.role === "member") { const { accessedEnvironments } = await findMemberByUserId(
const { accessedEnvironments } = await findMemberById(
ctx.user.id, ctx.user.id,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
@@ -305,6 +275,14 @@ export const environmentRouter = createTRPCRouter({
environmentId, environmentId,
updateData, updateData,
); );
if (environment) {
await audit(ctx, {
action: "update",
resourceType: "environment",
resourceId: environment.environmentId,
resourceName: environment.name,
});
}
return environment; return environment;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -318,14 +296,7 @@ export const environmentRouter = createTRPCRouter({
.input(apiDuplicateEnvironment) .input(apiDuplicateEnvironment)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
if (ctx.user.role === "member") { await checkEnvironmentAccess(ctx, input.environmentId, "read");
await checkEnvironmentAccess(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
const environment = await findEnvironmentById(input.environmentId); const environment = await findEnvironmentById(input.environmentId);
if ( if (
environment.project.organizationId !== environment.project.organizationId !==
@@ -337,9 +308,8 @@ export const environmentRouter = createTRPCRouter({
}); });
} }
// Check environment access for members if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
if (ctx.user.role === "member") { const { accessedEnvironments } = await findMemberByUserId(
const { accessedEnvironments } = await findMemberById(
ctx.user.id, ctx.user.id,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
@@ -353,6 +323,13 @@ export const environmentRouter = createTRPCRouter({
} }
const duplicatedEnvironment = await duplicateEnvironment(input); const duplicatedEnvironment = await duplicateEnvironment(input);
await audit(ctx, {
action: "create",
resourceType: "environment",
resourceId: duplicatedEnvironment.environmentId,
resourceName: duplicatedEnvironment.name,
metadata: { duplicatedFrom: input.environmentId },
});
return duplicatedEnvironment; return duplicatedEnvironment;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -404,8 +381,8 @@ export const environmentRouter = createTRPCRouter({
); );
} }
if (ctx.user.role === "member") { if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedEnvironments } = await findMemberById( const { accessedEnvironments } = await findMemberByUserId(
ctx.user.id, ctx.user.id,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );

View File

@@ -2,7 +2,12 @@ import { findGitProviderById, removeGitProvider } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { audit } from "@/server/api/utils/audit";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { apiRemoveGitProvider, gitProvider } from "@/server/db/schema"; import { apiRemoveGitProvider, gitProvider } from "@/server/db/schema";
export const gitProviderRouter = createTRPCRouter({ export const gitProviderRouter = createTRPCRouter({
@@ -21,7 +26,7 @@ export const gitProviderRouter = createTRPCRouter({
), ),
}); });
}), }),
remove: protectedProcedure remove: withPermission("gitProviders", "delete")
.input(apiRemoveGitProvider) .input(apiRemoveGitProvider)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -33,6 +38,12 @@ export const gitProviderRouter = createTRPCRouter({
message: "You are not allowed to delete this Git provider", message: "You are not allowed to delete this Git provider",
}); });
} }
await audit(ctx, {
action: "delete",
resourceType: "gitProvider",
resourceId: gitProvider.gitProviderId,
resourceName: gitProvider.name ?? gitProvider.gitProviderId,
});
return await removeGitProvider(input.gitProviderId); return await removeGitProvider(input.gitProviderId);
} catch (error) { } catch (error) {
const message = const message =

View File

@@ -10,7 +10,12 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; 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 { import {
apiCreateGitea, apiCreateGitea,
apiFindGiteaBranches, apiFindGiteaBranches,
@@ -20,15 +25,24 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const giteaRouter = createTRPCRouter({ export const giteaRouter = createTRPCRouter({
create: protectedProcedure create: withPermission("gitProviders", "create")
.input(apiCreateGitea) .input(apiCreateGitea)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createGitea( const result = await createGitea(
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
ctx.session.userId, ctx.session.userId,
); );
await audit(ctx, {
action: "create",
resourceType: "gitProvider",
resourceId: result.giteaId,
resourceName: input.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -38,24 +52,11 @@ export const giteaRouter = createTRPCRouter({
} }
}), }),
one: protectedProcedure one: protectedProcedure.input(apiFindOneGitea).query(async ({ input }) => {
.input(apiFindOneGitea) return await findGiteaById(input.giteaId);
.query(async ({ input, ctx }) => { }),
const giteaProvider = await findGiteaById(input.giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
return giteaProvider;
}),
giteaProviders: protectedProcedure.query(async ({ ctx }: { ctx: any }) => { giteaProviders: protectedProcedure.query(async ({ ctx }) => {
let result = await db.query.gitea.findMany({ let result = await db.query.gitea.findMany({
with: { with: {
gitProvider: true, gitProvider: true,
@@ -85,7 +86,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaRepositories: protectedProcedure getGiteaRepositories: protectedProcedure
.input(apiFindOneGitea) .input(apiFindOneGitea)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const { giteaId } = input; const { giteaId } = input;
if (!giteaId) { if (!giteaId) {
@@ -95,18 +96,6 @@ export const giteaRouter = createTRPCRouter({
}); });
} }
const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
try { try {
const repositories = await getGiteaRepositories(giteaId); const repositories = await getGiteaRepositories(giteaId);
return repositories; return repositories;
@@ -121,7 +110,7 @@ export const giteaRouter = createTRPCRouter({
getGiteaBranches: protectedProcedure getGiteaBranches: protectedProcedure
.input(apiFindGiteaBranches) .input(apiFindGiteaBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const { giteaId, owner, repositoryName } = input; const { giteaId, owner, repositoryName } = input;
if (!giteaId || !owner || !repositoryName) { if (!giteaId || !owner || !repositoryName) {
@@ -132,18 +121,6 @@ export const giteaRouter = createTRPCRouter({
}); });
} }
const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
try { try {
return await getGiteaBranches({ return await getGiteaBranches({
giteaId, giteaId,
@@ -161,22 +138,10 @@ export const giteaRouter = createTRPCRouter({
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiGiteaTestConnection) .input(apiGiteaTestConnection)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
const giteaId = input.giteaId ?? ""; const giteaId = input.giteaId ?? "";
try { try {
const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
const result = await testGiteaConnection({ const result = await testGiteaConnection({
giteaId, giteaId,
}); });
@@ -191,21 +156,9 @@ export const giteaRouter = createTRPCRouter({
} }
}), }),
update: protectedProcedure update: withPermission("gitProviders", "create")
.input(apiUpdateGitea) .input(apiUpdateGitea)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const giteaProvider = await findGiteaById(input.giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
if (input.name) { if (input.name) {
await updateGitProvider(input.gitProviderId, { await updateGitProvider(input.gitProviderId, {
name: input.name, name: input.name,
@@ -221,12 +174,19 @@ export const giteaRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.giteaId,
resourceName: input.name,
});
return { success: true }; return { success: true };
}), }),
getGiteaUrl: protectedProcedure getGiteaUrl: protectedProcedure
.input(apiFindOneGitea) .input(apiFindOneGitea)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const { giteaId } = input; const { giteaId } = input;
if (!giteaId) { if (!giteaId) {
@@ -237,16 +197,6 @@ export const giteaRouter = createTRPCRouter({
} }
const giteaProvider = await findGiteaById(giteaId); const giteaProvider = await findGiteaById(giteaId);
if (
giteaProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
giteaProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitea provider",
});
}
// Return the base URL of the Gitea instance // Return the base URL of the Gitea instance
return giteaProvider.giteaUrl; return giteaProvider.giteaUrl;

View File

@@ -8,7 +8,12 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; 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 { import {
apiFindGithubBranches, apiFindGithubBranches,
apiFindOneGithub, apiFindOneGithub,
@@ -16,53 +21,17 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const githubRouter = createTRPCRouter({ export const githubRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure.input(apiFindOneGithub).query(async ({ input }) => {
.input(apiFindOneGithub) return await findGithubById(input.githubId);
.query(async ({ input, ctx }) => { }),
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
return githubProvider;
}),
getGithubRepositories: protectedProcedure getGithubRepositories: protectedProcedure
.input(apiFindOneGithub) .input(apiFindOneGithub)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
return await getGithubRepositories(input.githubId); return await getGithubRepositories(input.githubId);
}), }),
getGithubBranches: protectedProcedure getGithubBranches: protectedProcedure
.input(apiFindGithubBranches) .input(apiFindGithubBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const githubProvider = await findGithubById(input.githubId || "");
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
//TODO: Remove this line when the cloud version is ready
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
return await getGithubBranches(input); return await getGithubBranches(input);
}), }),
githubProviders: protectedProcedure.query(async ({ ctx }) => { githubProviders: protectedProcedure.query(async ({ ctx }) => {
@@ -95,19 +64,8 @@ export const githubRouter = createTRPCRouter({
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiFindOneGithub) .input(apiFindOneGithub)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
try { try {
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
const result = await getGithubRepositories(input.githubId); const result = await getGithubRepositories(input.githubId);
return `Found ${result.length} repositories`; return `Found ${result.length} repositories`;
} catch (err) { } catch (err) {
@@ -117,20 +75,9 @@ export const githubRouter = createTRPCRouter({
}); });
} }
}), }),
update: protectedProcedure update: withPermission("gitProviders", "create")
.input(apiUpdateGithub) .input(apiUpdateGithub)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const githubProvider = await findGithubById(input.githubId);
if (
githubProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
githubProvider.gitProvider.userId === ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this github provider",
});
}
await updateGitProvider(input.gitProviderId, { await updateGitProvider(input.gitProviderId, {
name: input.name, name: input.name,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
@@ -139,5 +86,12 @@ export const githubRouter = createTRPCRouter({
await updateGithub(input.githubId, { await updateGithub(input.githubId, {
...input, ...input,
}); });
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.gitProviderId,
resourceName: input.name,
});
}), }),
}); });

View File

@@ -10,7 +10,12 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; 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 { import {
apiCreateGitlab, apiCreateGitlab,
apiFindGitlabBranches, apiFindGitlabBranches,
@@ -20,15 +25,23 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const gitlabRouter = createTRPCRouter({ export const gitlabRouter = createTRPCRouter({
create: protectedProcedure create: withPermission("gitProviders", "create")
.input(apiCreateGitlab) .input(apiCreateGitlab)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createGitlab( const result = await createGitlab(
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
ctx.session.userId, ctx.session.userId,
); );
await audit(ctx, {
action: "create",
resourceType: "gitProvider",
resourceName: input.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -37,22 +50,9 @@ export const gitlabRouter = createTRPCRouter({
}); });
} }
}), }),
one: protectedProcedure one: protectedProcedure.input(apiFindOneGitlab).query(async ({ input }) => {
.input(apiFindOneGitlab) return await findGitlabById(input.gitlabId);
.query(async ({ input, ctx }) => { }),
const gitlabProvider = await findGitlabById(input.gitlabId);
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
return gitlabProvider;
}),
gitlabProviders: protectedProcedure.query(async ({ ctx }) => { gitlabProviders: protectedProcedure.query(async ({ ctx }) => {
let result = await db.query.gitlab.findMany({ let result = await db.query.gitlab.findMany({
with: { with: {
@@ -83,52 +83,19 @@ export const gitlabRouter = createTRPCRouter({
}), }),
getGitlabRepositories: protectedProcedure getGitlabRepositories: protectedProcedure
.input(apiFindOneGitlab) .input(apiFindOneGitlab)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const gitlabProvider = await findGitlabById(input.gitlabId);
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
return await getGitlabRepositories(input.gitlabId); return await getGitlabRepositories(input.gitlabId);
}), }),
getGitlabBranches: protectedProcedure getGitlabBranches: protectedProcedure
.input(apiFindGitlabBranches) .input(apiFindGitlabBranches)
.query(async ({ input, ctx }) => { .query(async ({ input }) => {
const gitlabProvider = await findGitlabById(input.gitlabId || "");
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
return await getGitlabBranches(input); return await getGitlabBranches(input);
}), }),
testConnection: protectedProcedure testConnection: protectedProcedure
.input(apiGitlabTestConnection) .input(apiGitlabTestConnection)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
try { try {
const gitlabProvider = await findGitlabById(input.gitlabId || "");
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
const result = await testGitlabConnection(input); const result = await testGitlabConnection(input);
return `Found ${result} repositories`; return `Found ${result} repositories`;
@@ -139,20 +106,9 @@ export const gitlabRouter = createTRPCRouter({
}); });
} }
}), }),
update: protectedProcedure update: withPermission("gitProviders", "create")
.input(apiUpdateGitlab) .input(apiUpdateGitlab)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const gitlabProvider = await findGitlabById(input.gitlabId);
if (
gitlabProvider.gitProvider.organizationId !==
ctx.session.activeOrganizationId &&
gitlabProvider.gitProvider.userId !== ctx.session.userId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this Gitlab provider",
});
}
if (input.name) { if (input.name) {
await updateGitProvider(input.gitProviderId, { await updateGitProvider(input.gitProviderId, {
name: input.name, name: input.name,
@@ -167,5 +123,12 @@ export const gitlabRouter = createTRPCRouter({
...input, ...input,
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "gitProvider",
resourceId: input.gitProviderId,
resourceName: input.name,
});
}), }),
}); });

View File

@@ -1,14 +1,11 @@
import { import {
addNewService,
checkPortInUse, checkPortInUse,
checkServiceAccess,
createMariadb, createMariadb,
createMount, createMount,
deployMariadb, deployMariadb,
findBackupsByDbId, findBackupsByDbId,
findEnvironmentById, findEnvironmentById,
findMariadbById, findMariadbById,
findMemberById,
findProjectById, findProjectById,
IS_CLOUD, IS_CLOUD,
rebuildDatabase, rebuildDatabase,
@@ -21,11 +18,18 @@ import {
updateMariadbById, updateMariadbById,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { import {
apiChangeMariaDBStatus, apiChangeMariaDBStatus,
apiCreateMariaDB, apiCreateMariaDB,
@@ -36,27 +40,20 @@ import {
apiSaveEnvironmentVariablesMariaDB, apiSaveEnvironmentVariablesMariaDB,
apiSaveExternalPortMariaDB, apiSaveExternalPortMariaDB,
apiUpdateMariaDB, apiUpdateMariaDB,
environments,
mariadb as mariadbTable, mariadb as mariadbTable,
projects,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { environments, projects } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup"; import { cancelJobs } from "@/server/utils/backup";
export const mariadbRouter = createTRPCRouter({ export const mariadbRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreateMariaDB) .input(apiCreateMariaDB)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId); const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") { await checkServiceAccess(ctx, project.projectId, "create");
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -74,13 +71,7 @@ export const mariadbRouter = createTRPCRouter({
const newMariadb = await createMariadb({ const newMariadb = await createMariadb({
...input, ...input,
}); });
if (ctx.user.role === "member") { await addNewService(ctx, newMariadb.mariadbId);
await addNewService(
ctx.user.id,
newMariadb.mariadbId,
project.organizationId,
);
}
await createMount({ await createMount({
serviceId: newMariadb.mariadbId, serviceId: newMariadb.mariadbId,
@@ -90,6 +81,12 @@ export const mariadbRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newMariadb.mariadbId,
resourceName: newMariadb.appName,
});
return newMariadb; return newMariadb;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
@@ -101,14 +98,7 @@ export const mariadbRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure
.input(apiFindOneMariaDB) .input(apiFindOneMariaDB)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.mariadbId, "read");
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
ctx.session.activeOrganizationId,
"access",
);
}
const mariadb = await findMariadbById(input.mariadbId); const mariadb = await findMariadbById(input.mariadbId);
if ( if (
mariadb.environment.project.organizationId !== mariadb.environment.project.organizationId !==
@@ -125,16 +115,10 @@ export const mariadbRouter = createTRPCRouter({
start: protectedProcedure start: protectedProcedure
.input(apiFindOneMariaDB) .input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const service = await findMariadbById(input.mariadbId); const service = await findMariadbById(input.mariadbId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this Mariadb",
});
}
if (service.serverId) { if (service.serverId) {
await startServiceRemote(service.serverId, service.appName); await startServiceRemote(service.serverId, service.appName);
} else { } else {
@@ -144,11 +128,20 @@ export const mariadbRouter = createTRPCRouter({
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.mariadbId,
resourceName: service.appName,
});
return service; return service;
}), }),
stop: protectedProcedure stop: protectedProcedure
.input(apiFindOneMariaDB) .input(apiFindOneMariaDB)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId); const mariadb = await findMariadbById(input.mariadbId);
if (mariadb.serverId) { if (mariadb.serverId) {
@@ -160,21 +153,21 @@ export const mariadbRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
}); });
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return mariadb; return mariadb;
}), }),
saveExternalPort: protectedProcedure saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMariaDB) .input(apiSaveExternalPortMariaDB)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId); const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) { if (input.externalPort) {
const portCheck = await checkPortInUse( const portCheck = await checkPortInUse(
@@ -193,22 +186,28 @@ export const mariadbRouter = createTRPCRouter({
externalPort: input.externalPort, externalPort: input.externalPort,
}); });
await deployMariadb(input.mariadbId); await deployMariadb(input.mariadbId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return mariadb; return mariadb;
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiDeployMariaDB) .input(apiDeployMariaDB)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId); const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Mariadb",
});
}
await audit(ctx, {
action: "deploy",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return deployMariadb(input.mariadbId); return deployMariadb(input.mariadbId);
}), }),
deployWithLogs: protectedProcedure deployWithLogs: protectedProcedure
@@ -222,16 +221,9 @@ export const mariadbRouter = createTRPCRouter({
}) })
.input(apiDeployMariaDB) .input(apiDeployMariaDB)
.subscription(async ({ input, ctx }) => { .subscription(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId); await checkServicePermissionAndAccess(ctx, input.mariadbId, {
if ( deployment: ["create"],
mariadb.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Mariadb",
});
}
return observable<string>((emit) => { return observable<string>((emit) => {
deployMariadb(input.mariadbId, (log) => { deployMariadb(input.mariadbId, (log) => {
@@ -242,32 +234,25 @@ export const mariadbRouter = createTRPCRouter({
changeStatus: protectedProcedure changeStatus: protectedProcedure
.input(apiChangeMariaDBStatus) .input(apiChangeMariaDBStatus)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mongo = await findMariadbById(input.mariadbId); const mongo = await findMariadbById(input.mariadbId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this Mariadb status",
});
}
await updateMariadbById(input.mariadbId, { await updateMariadbById(input.mariadbId, {
applicationStatus: input.applicationStatus, applicationStatus: input.applicationStatus,
}); });
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mariadbId,
resourceName: mongo.appName,
});
return mongo; return mongo;
}), }),
remove: protectedProcedure remove: protectedProcedure
.input(apiFindOneMariaDB) .input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.mariadbId, "delete");
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
ctx.session.activeOrganizationId,
"delete",
);
}
const mongo = await findMariadbById(input.mariadbId); const mongo = await findMariadbById(input.mariadbId);
if ( if (
@@ -280,6 +265,12 @@ export const mariadbRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: mongo.mariadbId,
resourceName: mongo.appName,
});
const backups = await findBackupsByDbId(input.mariadbId, "mariadb"); const backups = await findBackupsByDbId(input.mariadbId, "mariadb");
const cleanupOperations = [ const cleanupOperations = [
async () => await removeService(mongo?.appName, mongo.serverId), async () => await removeService(mongo?.appName, mongo.serverId),
@@ -298,16 +289,9 @@ export const mariadbRouter = createTRPCRouter({
saveEnvironment: protectedProcedure saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesMariaDB) .input(apiSaveEnvironmentVariablesMariaDB)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId); await checkServicePermissionAndAccess(ctx, input.mariadbId, {
if ( envVars: ["write"],
mariadb.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
const service = await updateMariadbById(input.mariadbId, { const service = await updateMariadbById(input.mariadbId, {
env: input.env, env: input.env,
}); });
@@ -319,21 +303,20 @@ export const mariadbRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.mariadbId,
});
return true; return true;
}), }),
reload: protectedProcedure reload: protectedProcedure
.input(apiResetMariadb) .input(apiResetMariadb)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
deployment: ["create"],
});
const mariadb = await findMariadbById(input.mariadbId); const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this Mariadb",
});
}
if (mariadb.serverId) { if (mariadb.serverId) {
await stopServiceRemote(mariadb.serverId, mariadb.appName); await stopServiceRemote(mariadb.serverId, mariadb.appName);
} else { } else {
@@ -351,22 +334,21 @@ export const mariadbRouter = createTRPCRouter({
await updateMariadbById(input.mariadbId, { await updateMariadbById(input.mariadbId, {
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: mariadb.mariadbId,
resourceName: mariadb.appName,
});
return true; return true;
}), }),
update: protectedProcedure update: protectedProcedure
.input(apiUpdateMariaDB) .input(apiUpdateMariaDB)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { mariadbId, ...rest } = input; const { mariadbId, ...rest } = input;
const mariadb = await findMariadbById(mariadbId); await checkServicePermissionAndAccess(ctx, mariadbId, {
if ( service: ["create"],
mariadb.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this Mariadb",
});
}
const service = await updateMariadbById(mariadbId, { const service = await updateMariadbById(mariadbId, {
...rest, ...rest,
}); });
@@ -378,6 +360,12 @@ export const mariadbRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mariadbId,
resourceName: service.appName,
});
return true; return true;
}), }),
move: protectedProcedure move: protectedProcedure
@@ -388,31 +376,10 @@ export const mariadbRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId); await checkServicePermissionAndAccess(ctx, input.mariadbId, {
if ( service: ["create"],
mariadb.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mariadb",
});
}
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 mariadb's projectId
const updatedMariadb = await db const updatedMariadb = await db
.update(mariadbTable) .update(mariadbTable)
.set({ .set({
@@ -429,23 +396,27 @@ export const mariadbRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedMariadb.mariadbId,
resourceName: updatedMariadb.appName,
});
return updatedMariadb; return updatedMariadb;
}), }),
rebuild: protectedProcedure rebuild: protectedProcedure
.input(apiRebuildMariadb) .input(apiRebuildMariadb)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId); await checkServicePermissionAndAccess(ctx, input.mariadbId, {
if ( deployment: ["create"],
mariadb.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this MariaDB database",
});
}
await rebuildDatabase(mariadb.mariadbId, "mariadb"); await rebuildDatabase(input.mariadbId, "mariadb");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.mariadbId,
});
return true; return true;
}), }),
search: protectedProcedure search: protectedProcedure
@@ -499,19 +470,18 @@ export const mariadbRouter = createTRPCRouter({
), ),
); );
} }
if (ctx.user.role === "member") { const { accessedServices } = await findMemberByUserId(
const { accessedServices } = await findMemberById( ctx.user.id,
ctx.user.id, ctx.session.activeOrganizationId,
ctx.session.activeOrganizationId, );
); if (accessedServices.length === 0) return { items: [], total: 0 };
if (accessedServices.length === 0) return { items: [], total: 0 }; baseConditions.push(
baseConditions.push( sql`${mariadbTable.mariadbId} IN (${sql.join(
sql`${mariadbTable.mariadbId} IN (${sql.join( accessedServices.map((id) => sql`${id}`),
accessedServices.map((id) => sql`${id}`), sql`, `,
sql`, `, )})`,
)})`, );
);
}
const where = and(...baseConditions); const where = and(...baseConditions);
const [items, countResult] = await Promise.all([ const [items, countResult] = await Promise.all([
db db

View File

@@ -1,13 +1,10 @@
import { import {
addNewService,
checkPortInUse, checkPortInUse,
checkServiceAccess,
createMongo, createMongo,
createMount, createMount,
deployMongo, deployMongo,
findBackupsByDbId, findBackupsByDbId,
findEnvironmentById, findEnvironmentById,
findMemberById,
findMongoById, findMongoById,
findProjectById, findProjectById,
IS_CLOUD, IS_CLOUD,
@@ -20,10 +17,17 @@ import {
stopServiceRemote, stopServiceRemote,
updateMongoById, updateMongoById,
} from "@dokploy/server"; } from "@dokploy/server";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { import {
apiChangeMongoStatus, apiChangeMongoStatus,
@@ -44,18 +48,10 @@ export const mongoRouter = createTRPCRouter({
.input(apiCreateMongo) .input(apiCreateMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId); const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") { await checkServiceAccess(ctx, project.projectId, "create");
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -73,13 +69,7 @@ export const mongoRouter = createTRPCRouter({
const newMongo = await createMongo({ const newMongo = await createMongo({
...input, ...input,
}); });
if (ctx.user.role === "member") { await addNewService(ctx, newMongo.mongoId);
await addNewService(
ctx.user.id,
newMongo.mongoId,
project.organizationId,
);
}
await createMount({ await createMount({
serviceId: newMongo.mongoId, serviceId: newMongo.mongoId,
@@ -89,6 +79,12 @@ export const mongoRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newMongo.mongoId,
resourceName: newMongo.appName,
});
return newMongo; return newMongo;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
@@ -104,14 +100,7 @@ export const mongoRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure
.input(apiFindOneMongo) .input(apiFindOneMongo)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.mongoId, "read");
await checkServiceAccess(
ctx.user.id,
input.mongoId,
ctx.session.activeOrganizationId,
"access",
);
}
const mongo = await findMongoById(input.mongoId); const mongo = await findMongoById(input.mongoId);
if ( if (
@@ -129,18 +118,11 @@ export const mongoRouter = createTRPCRouter({
start: protectedProcedure start: protectedProcedure
.input(apiFindOneMongo) .input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const service = await findMongoById(input.mongoId); const service = await findMongoById(input.mongoId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this mongo",
});
}
if (service.serverId) { if (service.serverId) {
await startServiceRemote(service.serverId, service.appName); await startServiceRemote(service.serverId, service.appName);
} else { } else {
@@ -150,23 +132,22 @@ export const mongoRouter = createTRPCRouter({
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.mongoId,
resourceName: service.appName,
});
return service; return service;
}), }),
stop: protectedProcedure stop: protectedProcedure
.input(apiFindOneMongo) .input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId); const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this mongo",
});
}
if (mongo.serverId) { if (mongo.serverId) {
await stopServiceRemote(mongo.serverId, mongo.appName); await stopServiceRemote(mongo.serverId, mongo.appName);
} else { } else {
@@ -176,21 +157,21 @@ export const mongoRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
}); });
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return mongo; return mongo;
}), }),
saveExternalPort: protectedProcedure saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMongo) .input(apiSaveExternalPortMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["create"],
});
const mongo = await findMongoById(input.mongoId); const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) { if (input.externalPort) {
const portCheck = await checkPortInUse( const portCheck = await checkPortInUse(
@@ -209,21 +190,27 @@ export const mongoRouter = createTRPCRouter({
externalPort: input.externalPort, externalPort: input.externalPort,
}); });
await deployMongo(input.mongoId); await deployMongo(input.mongoId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return mongo; return mongo;
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiDeployMongo) .input(apiDeployMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId); const mongo = await findMongoById(input.mongoId);
if ( await audit(ctx, {
mongo.environment.project.organizationId !== action: "deploy",
ctx.session.activeOrganizationId resourceType: "service",
) { resourceId: mongo.mongoId,
throw new TRPCError({ resourceName: mongo.appName,
code: "UNAUTHORIZED", });
message: "You are not authorized to deploy this mongo",
});
}
return deployMongo(input.mongoId); return deployMongo(input.mongoId);
}), }),
deployWithLogs: protectedProcedure deployWithLogs: protectedProcedure
@@ -237,16 +224,9 @@ export const mongoRouter = createTRPCRouter({
}) })
.input(apiDeployMongo) .input(apiDeployMongo)
.subscription(async function* ({ input, ctx, signal }) { .subscription(async function* ({ input, ctx, signal }) {
const mongo = await findMongoById(input.mongoId); await checkServicePermissionAndAccess(ctx, input.mongoId, {
if ( deployment: ["create"],
mongo.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this mongo",
});
}
const queue: string[] = []; const queue: string[] = [];
const done = false; const done = false;
@@ -270,34 +250,28 @@ export const mongoRouter = createTRPCRouter({
changeStatus: protectedProcedure changeStatus: protectedProcedure
.input(apiChangeMongoStatus) .input(apiChangeMongoStatus)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId); const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this mongo status",
});
}
await updateMongoById(input.mongoId, { await updateMongoById(input.mongoId, {
applicationStatus: input.applicationStatus, applicationStatus: input.applicationStatus,
}); });
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return mongo; return mongo;
}), }),
reload: protectedProcedure reload: protectedProcedure
.input(apiResetMongo) .input(apiResetMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mongoId, {
deployment: ["create"],
});
const mongo = await findMongoById(input.mongoId); const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this mongo",
});
}
if (mongo.serverId) { if (mongo.serverId) {
await stopServiceRemote(mongo.serverId, mongo.appName); await stopServiceRemote(mongo.serverId, mongo.appName);
} else { } else {
@@ -315,19 +289,18 @@ export const mongoRouter = createTRPCRouter({
await updateMongoById(input.mongoId, { await updateMongoById(input.mongoId, {
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
return true; return true;
}), }),
remove: protectedProcedure remove: protectedProcedure
.input(apiFindOneMongo) .input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.mongoId, "delete");
await checkServiceAccess(
ctx.user.id,
input.mongoId,
ctx.session.activeOrganizationId,
"delete",
);
}
const mongo = await findMongoById(input.mongoId); const mongo = await findMongoById(input.mongoId);
@@ -340,6 +313,12 @@ export const mongoRouter = createTRPCRouter({
message: "You are not authorized to delete this mongo", message: "You are not authorized to delete this mongo",
}); });
} }
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: mongo.mongoId,
resourceName: mongo.appName,
});
const backups = await findBackupsByDbId(input.mongoId, "mongo"); const backups = await findBackupsByDbId(input.mongoId, "mongo");
const cleanupOperations = [ const cleanupOperations = [
@@ -359,16 +338,9 @@ export const mongoRouter = createTRPCRouter({
saveEnvironment: protectedProcedure saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesMongo) .input(apiSaveEnvironmentVariablesMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId); await checkServicePermissionAndAccess(ctx, input.mongoId, {
if ( envVars: ["write"],
mongo.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
const service = await updateMongoById(input.mongoId, { const service = await updateMongoById(input.mongoId, {
env: input.env, env: input.env,
}); });
@@ -380,22 +352,20 @@ export const mongoRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.mongoId,
});
return true; return true;
}), }),
update: protectedProcedure update: protectedProcedure
.input(apiUpdateMongo) .input(apiUpdateMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { mongoId, ...rest } = input; const { mongoId, ...rest } = input;
const mongo = await findMongoById(mongoId); await checkServicePermissionAndAccess(ctx, mongoId, {
if ( service: ["create"],
mongo.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this mongo",
});
}
const service = await updateMongoById(mongoId, { const service = await updateMongoById(mongoId, {
...rest, ...rest,
}); });
@@ -407,6 +377,12 @@ export const mongoRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongoId,
resourceName: service.appName,
});
return true; return true;
}), }),
move: protectedProcedure move: protectedProcedure
@@ -417,31 +393,10 @@ export const mongoRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId); await checkServicePermissionAndAccess(ctx, input.mongoId, {
if ( service: ["create"],
mongo.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mongo",
});
}
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 mongo's projectId
const updatedMongo = await db const updatedMongo = await db
.update(mongoTable) .update(mongoTable)
.set({ .set({
@@ -458,24 +413,28 @@ export const mongoRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedMongo.mongoId,
resourceName: updatedMongo.appName,
});
return updatedMongo; return updatedMongo;
}), }),
rebuild: protectedProcedure rebuild: protectedProcedure
.input(apiRebuildMongo) .input(apiRebuildMongo)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId); await checkServicePermissionAndAccess(ctx, input.mongoId, {
if ( deployment: ["create"],
mongo.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this MongoDB database",
});
}
await rebuildDatabase(mongo.mongoId, "mongo"); await rebuildDatabase(input.mongoId, "mongo");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.mongoId,
});
return true; return true;
}), }),
search: protectedProcedure search: protectedProcedure
@@ -524,19 +483,18 @@ export const mongoRouter = createTRPCRouter({
ilike(mongoTable.description ?? "", `%${input.description.trim()}%`), ilike(mongoTable.description ?? "", `%${input.description.trim()}%`),
); );
} }
if (ctx.user.role === "member") { const { accessedServices } = await findMemberByUserId(
const { accessedServices } = await findMemberById( ctx.user.id,
ctx.user.id, ctx.session.activeOrganizationId,
ctx.session.activeOrganizationId, );
); if (accessedServices.length === 0) return { items: [], total: 0 };
if (accessedServices.length === 0) return { items: [], total: 0 }; baseConditions.push(
baseConditions.push( sql`${mongoTable.mongoId} IN (${sql.join(
sql`${mongoTable.mongoId} IN (${sql.join( accessedServices.map((id) => sql`${id}`),
accessedServices.map((id) => sql`${id}`), sql`, `,
sql`, `, )})`,
)})`, );
);
}
const where = and(...baseConditions); const where = and(...baseConditions);
const [items, countResult] = await Promise.all([ const [items, countResult] = await Promise.all([
db db

View File

@@ -1,5 +1,4 @@
import { import {
checkServiceAccess,
createMount, createMount,
deleteMount, deleteMount,
findApplicationById, findApplicationById,
@@ -7,7 +6,6 @@ import {
findMariadbById, findMariadbById,
findMongoById, findMongoById,
findMountById, findMountById,
findMountOrganizationId,
findMountsByApplicationId, findMountsByApplicationId,
findMySqlById, findMySqlById,
findPostgresById, findPostgresById,
@@ -15,6 +13,10 @@ import {
getServiceContainer, getServiceContainer,
updateMount, updateMount,
} from "@dokploy/server"; } from "@dokploy/server";
import {
checkServiceAccess,
checkServicePermissionAndAccess,
} from "@dokploy/server/services/permission";
import type { ServiceType } from "@dokploy/server/db/schema/mount"; import type { ServiceType } from "@dokploy/server/db/schema/mount";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@@ -26,6 +28,7 @@ import {
apiUpdateMount, apiUpdateMount,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { audit } from "@/server/api/utils/audit";
async function getServiceOrganizationId( async function getServiceOrganizationId(
serviceId: string, serviceId: string,
@@ -68,49 +71,94 @@ async function getServiceOrganizationId(
export const mountRouter = createTRPCRouter({ export const mountRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreateMount) .input(apiCreateMount)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
return await createMount(input); await checkServicePermissionAndAccess(ctx, input.serviceId, {
volume: ["create"],
});
const mount = await createMount(input);
await audit(ctx, {
action: "create",
resourceType: "mount",
resourceId: mount.mountId,
resourceName: input.mountPath,
});
return mount;
}), }),
remove: protectedProcedure remove: protectedProcedure
.input(apiRemoveMount) .input(apiRemoveMount)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId); const mount = await findMountById(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) { const serviceId =
throw new TRPCError({ mount.applicationId ||
code: "UNAUTHORIZED", mount.postgresId ||
message: "You are not authorized to delete this mount", mount.mariadbId ||
mount.mongoId ||
mount.mysqlId ||
mount.redisId ||
mount.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volume: ["delete"],
}); });
} }
await audit(ctx, {
action: "delete",
resourceType: "mount",
resourceId: input.mountId,
});
return await deleteMount(input.mountId); return await deleteMount(input.mountId);
}), }),
one: protectedProcedure one: protectedProcedure
.input(apiFindOneMount) .input(apiFindOneMount)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId); const mount = await findMountById(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) { const serviceId =
throw new TRPCError({ mount.applicationId ||
code: "UNAUTHORIZED", mount.postgresId ||
message: "You are not authorized to access this mount", mount.mariadbId ||
mount.mongoId ||
mount.mysqlId ||
mount.redisId ||
mount.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volume: ["read"],
}); });
} }
return await findMountById(input.mountId); return mount;
}), }),
update: protectedProcedure update: protectedProcedure
.input(apiUpdateMount) .input(apiUpdateMount)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId); const mount = await findMountById(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) { const serviceId =
throw new TRPCError({ mount.applicationId ||
code: "UNAUTHORIZED", mount.postgresId ||
message: "You are not authorized to update this mount", mount.mariadbId ||
mount.mongoId ||
mount.mysqlId ||
mount.redisId ||
mount.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
volume: ["create"],
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "mount",
resourceId: input.mountId,
resourceName: input.mountPath,
});
return await updateMount(input.mountId, input); return await updateMount(input.mountId, input);
}), }),
allNamedByApplicationId: protectedProcedure allNamedByApplicationId: protectedProcedure
.input(z.object({ applicationId: z.string().min(1) })) .input(z.object({ applicationId: z.string().min(1) }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
volume: ["read"],
});
const app = await findApplicationById(input.applicationId); const app = await findApplicationById(input.applicationId);
const container = await getServiceContainer(app.appName, app.serverId); const container = await getServiceContainer(app.appName, app.serverId);
const mounts = container?.Mounts.filter( const mounts = container?.Mounts.filter(
@@ -122,14 +170,7 @@ export const mountRouter = createTRPCRouter({
.input(apiFindMountByApplicationId) .input(apiFindMountByApplicationId)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
console.log("input", input); console.log("input", input);
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.serviceId, "read");
await checkServiceAccess(
ctx.user.id,
input.serviceId,
ctx.session.activeOrganizationId,
"access",
);
}
const organizationId = await getServiceOrganizationId( const organizationId = await getServiceOrganizationId(
input.serviceId, input.serviceId,
input.serviceType, input.serviceType,

View File

@@ -1,13 +1,10 @@
import { import {
addNewService,
checkPortInUse, checkPortInUse,
checkServiceAccess,
createMount, createMount,
createMysql, createMysql,
deployMySql, deployMySql,
findBackupsByDbId, findBackupsByDbId,
findEnvironmentById, findEnvironmentById,
findMemberById,
findMySqlById, findMySqlById,
findProjectById, findProjectById,
IS_CLOUD, IS_CLOUD,
@@ -20,10 +17,17 @@ import {
stopServiceRemote, stopServiceRemote,
updateMySqlById, updateMySqlById,
} from "@dokploy/server"; } from "@dokploy/server";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { import {
apiChangeMySqlStatus, apiChangeMySqlStatus,
@@ -46,18 +50,10 @@ export const mysqlRouter = createTRPCRouter({
.input(apiCreateMySql) .input(apiCreateMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId); const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") { await checkServiceAccess(ctx, project.projectId, "create");
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -76,13 +72,7 @@ export const mysqlRouter = createTRPCRouter({
const newMysql = await createMysql({ const newMysql = await createMysql({
...input, ...input,
}); });
if (ctx.user.role === "member") { await addNewService(ctx, newMysql.mysqlId);
await addNewService(
ctx.user.id,
newMysql.mysqlId,
project.organizationId,
);
}
await createMount({ await createMount({
serviceId: newMysql.mysqlId, serviceId: newMysql.mysqlId,
@@ -92,6 +82,12 @@ export const mysqlRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newMysql.mysqlId,
resourceName: newMysql.appName,
});
return newMysql; return newMysql;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
@@ -107,14 +103,7 @@ export const mysqlRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure
.input(apiFindOneMySql) .input(apiFindOneMySql)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.mysqlId, "read");
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
ctx.session.activeOrganizationId,
"access",
);
}
const mysql = await findMySqlById(input.mysqlId); const mysql = await findMySqlById(input.mysqlId);
if ( if (
mysql.environment.project.organizationId !== mysql.environment.project.organizationId !==
@@ -131,16 +120,10 @@ export const mysqlRouter = createTRPCRouter({
start: protectedProcedure start: protectedProcedure
.input(apiFindOneMySql) .input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const service = await findMySqlById(input.mysqlId); const service = await findMySqlById(input.mysqlId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this MySQL",
});
}
if (service.serverId) { if (service.serverId) {
await startServiceRemote(service.serverId, service.appName); await startServiceRemote(service.serverId, service.appName);
@@ -151,21 +134,21 @@ export const mysqlRouter = createTRPCRouter({
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.mysqlId,
resourceName: service.appName,
});
return service; return service;
}), }),
stop: protectedProcedure stop: protectedProcedure
.input(apiFindOneMySql) .input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mongo = await findMySqlById(input.mysqlId); const mongo = await findMySqlById(input.mysqlId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this MySQL",
});
}
if (mongo.serverId) { if (mongo.serverId) {
await stopServiceRemote(mongo.serverId, mongo.appName); await stopServiceRemote(mongo.serverId, mongo.appName);
} else { } else {
@@ -175,21 +158,21 @@ export const mysqlRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
}); });
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: mongo.mysqlId,
resourceName: mongo.appName,
});
return mongo; return mongo;
}), }),
saveExternalPort: protectedProcedure saveExternalPort: protectedProcedure
.input(apiSaveExternalPortMySql) .input(apiSaveExternalPortMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["create"],
});
const mysql = await findMySqlById(input.mysqlId); const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) { if (input.externalPort) {
const portCheck = await checkPortInUse( const portCheck = await checkPortInUse(
@@ -208,21 +191,27 @@ export const mysqlRouter = createTRPCRouter({
externalPort: input.externalPort, externalPort: input.externalPort,
}); });
await deployMySql(input.mysqlId); await deployMySql(input.mysqlId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mysql.mysqlId,
resourceName: mysql.appName,
});
return mysql; return mysql;
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiDeployMySql) .input(apiDeployMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mysql = await findMySqlById(input.mysqlId); const mysql = await findMySqlById(input.mysqlId);
if ( await audit(ctx, {
mysql.environment.project.organizationId !== action: "deploy",
ctx.session.activeOrganizationId resourceType: "service",
) { resourceId: mysql.mysqlId,
throw new TRPCError({ resourceName: mysql.appName,
code: "UNAUTHORIZED", });
message: "You are not authorized to deploy this MySQL",
});
}
return deployMySql(input.mysqlId); return deployMySql(input.mysqlId);
}), }),
deployWithLogs: protectedProcedure deployWithLogs: protectedProcedure
@@ -236,16 +225,9 @@ export const mysqlRouter = createTRPCRouter({
}) })
.input(apiDeployMySql) .input(apiDeployMySql)
.subscription(async function* ({ input, ctx, signal }) { .subscription(async function* ({ input, ctx, signal }) {
const mysql = await findMySqlById(input.mysqlId); await checkServicePermissionAndAccess(ctx, input.mysqlId, {
if ( deployment: ["create"],
mysql.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this MySQL",
});
}
const queue: string[] = []; const queue: string[] = [];
const done = false; const done = false;
@@ -269,34 +251,28 @@ export const mysqlRouter = createTRPCRouter({
changeStatus: protectedProcedure changeStatus: protectedProcedure
.input(apiChangeMySqlStatus) .input(apiChangeMySqlStatus)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mongo = await findMySqlById(input.mysqlId); const mongo = await findMySqlById(input.mysqlId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this MySQL status",
});
}
await updateMySqlById(input.mysqlId, { await updateMySqlById(input.mysqlId, {
applicationStatus: input.applicationStatus, applicationStatus: input.applicationStatus,
}); });
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mongo.mysqlId,
resourceName: mongo.appName,
});
return mongo; return mongo;
}), }),
reload: protectedProcedure reload: protectedProcedure
.input(apiResetMysql) .input(apiResetMysql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
deployment: ["create"],
});
const mysql = await findMySqlById(input.mysqlId); const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this MySQL",
});
}
if (mysql.serverId) { if (mysql.serverId) {
await stopServiceRemote(mysql.serverId, mysql.appName); await stopServiceRemote(mysql.serverId, mysql.appName);
} else { } else {
@@ -313,19 +289,18 @@ export const mysqlRouter = createTRPCRouter({
await updateMySqlById(input.mysqlId, { await updateMySqlById(input.mysqlId, {
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: mysql.mysqlId,
resourceName: mysql.appName,
});
return true; return true;
}), }),
remove: protectedProcedure remove: protectedProcedure
.input(apiFindOneMySql) .input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.mysqlId, "delete");
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
ctx.session.activeOrganizationId,
"delete",
);
}
const mongo = await findMySqlById(input.mysqlId); const mongo = await findMySqlById(input.mysqlId);
if ( if (
mongo.environment.project.organizationId !== mongo.environment.project.organizationId !==
@@ -337,6 +312,12 @@ export const mysqlRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: mongo.mysqlId,
resourceName: mongo.appName,
});
const backups = await findBackupsByDbId(input.mysqlId, "mysql"); const backups = await findBackupsByDbId(input.mysqlId, "mysql");
const cleanupOperations = [ const cleanupOperations = [
async () => await removeService(mongo?.appName, mongo.serverId), async () => await removeService(mongo?.appName, mongo.serverId),
@@ -355,16 +336,9 @@ export const mysqlRouter = createTRPCRouter({
saveEnvironment: protectedProcedure saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesMySql) .input(apiSaveEnvironmentVariablesMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId); await checkServicePermissionAndAccess(ctx, input.mysqlId, {
if ( envVars: ["write"],
mysql.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
const service = await updateMySqlById(input.mysqlId, { const service = await updateMySqlById(input.mysqlId, {
env: input.env, env: input.env,
}); });
@@ -376,22 +350,20 @@ export const mysqlRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.mysqlId,
});
return true; return true;
}), }),
update: protectedProcedure update: protectedProcedure
.input(apiUpdateMySql) .input(apiUpdateMySql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { mysqlId, ...rest } = input; const { mysqlId, ...rest } = input;
const mysql = await findMySqlById(mysqlId); await checkServicePermissionAndAccess(ctx, mysqlId, {
if ( service: ["create"],
mysql.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this MySQL",
});
}
const service = await updateMySqlById(mysqlId, { const service = await updateMySqlById(mysqlId, {
...rest, ...rest,
}); });
@@ -403,6 +375,12 @@ export const mysqlRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: mysqlId,
resourceName: service.appName,
});
return true; return true;
}), }),
move: protectedProcedure move: protectedProcedure
@@ -413,31 +391,10 @@ export const mysqlRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId); await checkServicePermissionAndAccess(ctx, input.mysqlId, {
if ( service: ["create"],
mysql.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mysql",
});
}
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 mysql's projectId
const updatedMysql = await db const updatedMysql = await db
.update(mysqlTable) .update(mysqlTable)
.set({ .set({
@@ -454,24 +411,28 @@ export const mysqlRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedMysql.mysqlId,
resourceName: updatedMysql.appName,
});
return updatedMysql; return updatedMysql;
}), }),
rebuild: protectedProcedure rebuild: protectedProcedure
.input(apiRebuildMysql) .input(apiRebuildMysql)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId); await checkServicePermissionAndAccess(ctx, input.mysqlId, {
if ( deployment: ["create"],
mysql.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this MySQL database",
});
}
await rebuildDatabase(mysql.mysqlId, "mysql"); await rebuildDatabase(input.mysqlId, "mysql");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.mysqlId,
});
return true; return true;
}), }),
search: protectedProcedure search: protectedProcedure
@@ -520,19 +481,18 @@ export const mysqlRouter = createTRPCRouter({
ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`), ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`),
); );
} }
if (ctx.user.role === "member") { const { accessedServices } = await findMemberByUserId(
const { accessedServices } = await findMemberById( ctx.user.id,
ctx.user.id, ctx.session.activeOrganizationId,
ctx.session.activeOrganizationId, );
); if (accessedServices.length === 0) return { items: [], total: 0 };
if (accessedServices.length === 0) return { items: [], total: 0 }; baseConditions.push(
baseConditions.push( sql`${mysqlTable.mysqlId} IN (${sql.join(
sql`${mysqlTable.mysqlId} IN (${sql.join( accessedServices.map((id) => sql`${id}`),
accessedServices.map((id) => sql`${id}`), sql`, `,
sql`, `, )})`,
)})`, );
);
}
const where = and(...baseConditions); const where = and(...baseConditions);
const [items, countResult] = await Promise.all([ const [items, countResult] = await Promise.all([
db db

View File

@@ -43,11 +43,11 @@ import { TRPCError } from "@trpc/server";
import { desc, eq, sql } from "drizzle-orm"; import { desc, eq, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import {
adminProcedure,
createTRPCRouter, createTRPCRouter,
protectedProcedure,
publicProcedure, publicProcedure,
withPermission,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { import {
apiCreateCustom, apiCreateCustom,
apiCreateDiscord, apiCreateDiscord,
@@ -88,15 +88,18 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
export const notificationRouter = createTRPCRouter({ export const notificationRouter = createTRPCRouter({
createSlack: adminProcedure createSlack: withPermission("notification", "create")
.input(apiCreateSlack) .input(apiCreateSlack)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createSlackNotification( await createSlackNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
console.log(error);
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Error creating the notification", message: "Error creating the notification",
@@ -104,7 +107,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateSlack: adminProcedure updateSlack: withPermission("notification", "update")
.input(apiUpdateSlack) .input(apiUpdateSlack)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -115,15 +118,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateSlackNotification({ const result = await updateSlackNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
testSlackConnection: adminProcedure testSlackConnection: withPermission("notification", "create")
.input(apiTestSlackConnection) .input(apiTestSlackConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -140,14 +150,19 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createTelegram: adminProcedure createTelegram: withPermission("notification", "create")
.input(apiCreateTelegram) .input(apiCreateTelegram)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createTelegramNotification( await createTelegramNotification(
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -157,7 +172,7 @@ export const notificationRouter = createTRPCRouter({
} }
}), }),
updateTelegram: adminProcedure updateTelegram: withPermission("notification", "update")
.input(apiUpdateTelegram) .input(apiUpdateTelegram)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -168,10 +183,17 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateTelegramNotification({ const result = await updateTelegramNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -180,7 +202,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
testTelegramConnection: adminProcedure testTelegramConnection: withPermission("notification", "create")
.input(apiTestTelegramConnection) .input(apiTestTelegramConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -194,14 +216,19 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createDiscord: adminProcedure createDiscord: withPermission("notification", "create")
.input(apiCreateDiscord) .input(apiCreateDiscord)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createDiscordNotification( await createDiscordNotification(
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -211,7 +238,7 @@ export const notificationRouter = createTRPCRouter({
} }
}), }),
updateDiscord: adminProcedure updateDiscord: withPermission("notification", "update")
.input(apiUpdateDiscord) .input(apiUpdateDiscord)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -222,10 +249,17 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateDiscordNotification({ const result = await updateDiscordNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -235,7 +269,7 @@ export const notificationRouter = createTRPCRouter({
} }
}), }),
testDiscordConnection: adminProcedure testDiscordConnection: withPermission("notification", "create")
.input(apiTestDiscordConnection) .input(apiTestDiscordConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -257,14 +291,16 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createEmail: adminProcedure createEmail: withPermission("notification", "create")
.input(apiCreateEmail) .input(apiCreateEmail)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createEmailNotification( await createEmailNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -273,7 +309,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateEmail: adminProcedure updateEmail: withPermission("notification", "update")
.input(apiUpdateEmail) .input(apiUpdateEmail)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -284,10 +320,17 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateEmailNotification({ const result = await updateEmailNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -296,7 +339,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
testEmailConnection: adminProcedure testEmailConnection: withPermission("notification", "create")
.input(apiTestEmailConnection) .input(apiTestEmailConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -314,14 +357,16 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createResend: adminProcedure createResend: withPermission("notification", "create")
.input(apiCreateResend) .input(apiCreateResend)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createResendNotification( await createResendNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -330,7 +375,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateResend: adminProcedure updateResend: withPermission("notification", "update")
.input(apiUpdateResend) .input(apiUpdateResend)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -341,10 +386,17 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateResendNotification({ const result = await updateResendNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -353,7 +405,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
testResendConnection: adminProcedure testResendConnection: withPermission("notification", "create")
.input(apiTestResendConnection) .input(apiTestResendConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -371,7 +423,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
remove: adminProcedure remove: withPermission("notification", "delete")
.input(apiFindOneNotification) .input(apiFindOneNotification)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -382,6 +434,11 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to delete this notification", message: "You are not authorized to delete this notification",
}); });
} }
await audit(ctx, {
action: "delete",
resourceType: "notification",
resourceName: notification.name,
});
return await removeNotificationById(input.notificationId); return await removeNotificationById(input.notificationId);
} catch (error) { } catch (error) {
const message = const message =
@@ -394,7 +451,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
one: protectedProcedure one: withPermission("notification", "read")
.input(apiFindOneNotification) .input(apiFindOneNotification)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const notification = await findNotificationById(input.notificationId); const notification = await findNotificationById(input.notificationId);
@@ -406,7 +463,7 @@ export const notificationRouter = createTRPCRouter({
} }
return notification; return notification;
}), }),
all: adminProcedure.query(async ({ ctx }) => { all: withPermission("notification", "read").query(async ({ ctx }) => {
return await db.query.notifications.findMany({ return await db.query.notifications.findMany({
with: { with: {
slack: true, slack: true,
@@ -453,8 +510,6 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
// For Dokploy server type, we don't have a specific organizationId
// This might need to be adjusted based on your business logic
organizationId = ""; organizationId = "";
ServerName = "Dokploy"; ServerName = "Dokploy";
} else { } else {
@@ -488,14 +543,16 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createGotify: adminProcedure createGotify: withPermission("notification", "create")
.input(apiCreateGotify) .input(apiCreateGotify)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createGotifyNotification( await createGotifyNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -504,7 +561,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateGotify: adminProcedure updateGotify: withPermission("notification", "update")
.input(apiUpdateGotify) .input(apiUpdateGotify)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -518,15 +575,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateGotifyNotification({ const result = await updateGotifyNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
testGotifyConnection: adminProcedure testGotifyConnection: withPermission("notification", "create")
.input(apiTestGotifyConnection) .input(apiTestGotifyConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -544,14 +608,16 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createNtfy: adminProcedure createNtfy: withPermission("notification", "create")
.input(apiCreateNtfy) .input(apiCreateNtfy)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createNtfyNotification( await createNtfyNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -560,7 +626,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateNtfy: adminProcedure updateNtfy: withPermission("notification", "update")
.input(apiUpdateNtfy) .input(apiUpdateNtfy)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -574,15 +640,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateNtfyNotification({ const result = await updateNtfyNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
testNtfyConnection: adminProcedure testNtfyConnection: withPermission("notification", "create")
.input(apiTestNtfyConnection) .input(apiTestNtfyConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -602,14 +675,16 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createCustom: adminProcedure createCustom: withPermission("notification", "create")
.input(apiCreateCustom) .input(apiCreateCustom)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createCustomNotification( await createCustomNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -618,7 +693,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateCustom: adminProcedure updateCustom: withPermission("notification", "update")
.input(apiUpdateCustom) .input(apiUpdateCustom)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -629,15 +704,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateCustomNotification({ const result = await updateCustomNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
testCustomConnection: adminProcedure testCustomConnection: withPermission("notification", "create")
.input(apiTestCustomConnection) .input(apiTestCustomConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -655,14 +737,16 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createLark: adminProcedure createLark: withPermission("notification", "create")
.input(apiCreateLark) .input(apiCreateLark)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createLarkNotification( await createLarkNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -671,7 +755,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateLark: adminProcedure updateLark: withPermission("notification", "update")
.input(apiUpdateLark) .input(apiUpdateLark)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -685,15 +769,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateLarkNotification({ const result = await updateLarkNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
testLarkConnection: adminProcedure testLarkConnection: withPermission("notification", "create")
.input(apiTestLarkConnection) .input(apiTestLarkConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -712,14 +803,16 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createTeams: adminProcedure createTeams: withPermission("notification", "create")
.input(apiCreateTeams) .input(apiCreateTeams)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createTeamsNotification( await createTeamsNotification(input, ctx.session.activeOrganizationId);
input, await audit(ctx, {
ctx.session.activeOrganizationId, action: "create",
); resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -728,7 +821,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updateTeams: adminProcedure updateTeams: withPermission("notification", "update")
.input(apiUpdateTeams) .input(apiUpdateTeams)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -742,15 +835,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updateTeamsNotification({ const result = await updateTeamsNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
testTeamsConnection: adminProcedure testTeamsConnection: withPermission("notification", "create")
.input(apiTestTeamsConnection) .input(apiTestTeamsConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -767,14 +867,19 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createPushover: adminProcedure createPushover: withPermission("notification", "create")
.input(apiCreatePushover) .input(apiCreatePushover)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
return await createPushoverNotification( await createPushoverNotification(
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
await audit(ctx, {
action: "create",
resourceType: "notification",
resourceName: input.name,
});
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -783,7 +888,7 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
updatePushover: adminProcedure updatePushover: withPermission("notification", "update")
.input(apiUpdatePushover) .input(apiUpdatePushover)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
@@ -797,15 +902,22 @@ export const notificationRouter = createTRPCRouter({
message: "You are not authorized to update this notification", message: "You are not authorized to update this notification",
}); });
} }
return await updatePushoverNotification({ const result = await updatePushoverNotification({
...input, ...input,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await audit(ctx, {
action: "update",
resourceType: "notification",
resourceId: input.notificationId,
resourceName: notification.name,
});
return result;
} catch (error) { } catch (error) {
throw error; throw error;
} }
}), }),
testPushoverConnection: adminProcedure testPushoverConnection: withPermission("notification", "create")
.input(apiTestPushoverConnection) .input(apiTestPushoverConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
@@ -823,13 +935,18 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
getEmailProviders: adminProcedure.query(async ({ ctx }) => { getEmailProviders: withPermission("notification", "read").query(
return await db.query.notifications.findMany({ async ({ ctx }) => {
where: eq(notifications.organizationId, ctx.session.activeOrganizationId), return await db.query.notifications.findMany({
with: { where: eq(
email: true, notifications.organizationId,
resend: true, ctx.session.activeOrganizationId,
}, ),
}); with: {
}), email: true,
resend: true,
},
});
},
),
}); });

View File

@@ -1,11 +1,18 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { IS_CLOUD } from "@dokploy/server/index"; import { IS_CLOUD } from "@dokploy/server/index";
import { audit } from "@/server/api/utils/audit";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, exists } from "drizzle-orm"; import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { invitation, member, organization } from "@/server/db/schema"; import {
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc"; invitation,
member,
organization,
organizationRole,
user,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const organizationRouter = createTRPCRouter({ export const organizationRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input( .input(
@@ -50,6 +57,12 @@ export const organizationRouter = createTRPCRouter({
createdAt: new Date(), createdAt: new Date(),
userId: ctx.user.id, userId: ctx.user.id,
}); });
await audit(ctx, {
action: "create",
resourceType: "organization",
resourceId: result.id,
resourceName: result.name,
});
return result; return result;
}), }),
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
@@ -156,6 +169,12 @@ export const organizationRouter = createTRPCRouter({
}) })
.where(eq(organization.id, input.organizationId)) .where(eq(organization.id, input.organizationId))
.returning(); .returning();
await audit(ctx, {
action: "update",
resourceType: "organization",
resourceId: input.organizationId,
resourceName: input.name,
});
return result[0]; return result[0];
}), }),
delete: protectedProcedure delete: protectedProcedure
@@ -220,15 +239,109 @@ export const organizationRouter = createTRPCRouter({
.delete(organization) .delete(organization)
.where(eq(organization.id, input.organizationId)); .where(eq(organization.id, input.organizationId));
await audit(ctx, {
action: "delete",
resourceType: "organization",
resourceId: input.organizationId,
resourceName: org.name,
});
return result; return result;
}), }),
allInvitations: adminProcedure.query(async ({ ctx }) => { inviteMember: withPermission("member", "create")
.input(
z.object({
email: z.string().email(),
role: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const orgId = ctx.session.activeOrganizationId;
const email = input.email.toLowerCase();
// Check if user is already a member
const existingUser = await db.query.user.findFirst({
where: eq(user.email, email),
});
if (existingUser) {
const existingMember = await db.query.member.findFirst({
where: and(
eq(member.organizationId, orgId),
eq(member.userId, existingUser.id),
),
});
if (existingMember) {
throw new TRPCError({
code: "CONFLICT",
message: "User is already a member of this organization",
});
}
}
// Check for pending invitation
const existingInvitation = await db.query.invitation.findFirst({
where: and(
eq(invitation.organizationId, orgId),
eq(invitation.email, email),
eq(invitation.status, "pending"),
),
});
if (existingInvitation) {
throw new TRPCError({
code: "CONFLICT",
message: "An invitation has already been sent to this email",
});
}
// If assigning a custom role, verify it exists
if (!["owner", "admin", "member"].includes(input.role)) {
const customRole = await db.query.organizationRole.findFirst({
where: and(
eq(organizationRole.organizationId, orgId),
eq(organizationRole.role, input.role),
),
});
if (!customRole) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Role "${input.role}" not found`,
});
}
}
const [created] = await db
.insert(invitation)
.values({
id: nanoid(),
organizationId: orgId,
email,
role: input.role as any,
status: "pending",
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000),
inviterId: ctx.user.id,
})
.returning();
await audit(ctx, {
action: "create",
resourceType: "organization",
resourceId: created?.id,
resourceName: email,
metadata: { type: "inviteMember", role: input.role },
});
return created;
}),
allInvitations: withPermission("member", "create").query(async ({ ctx }) => {
return await db.query.invitation.findMany({ return await db.query.invitation.findMany({
where: eq(invitation.organizationId, ctx.session.activeOrganizationId), where: eq(invitation.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(invitation.status), desc(invitation.expiresAt)], orderBy: [desc(invitation.status), desc(invitation.expiresAt)],
}); });
}), }),
removeInvitation: adminProcedure removeInvitation: withPermission("member", "create")
.input(z.object({ invitationId: z.string() })) .input(z.object({ invitationId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const invitationResult = await db.query.invitation.findFirst({ const invitationResult = await db.query.invitation.findFirst({
@@ -251,15 +364,23 @@ export const organizationRouter = createTRPCRouter({
}); });
} }
return await db const result = await db
.delete(invitation) .delete(invitation)
.where(eq(invitation.id, input.invitationId)); .where(eq(invitation.id, input.invitationId));
await audit(ctx, {
action: "delete",
resourceType: "organization",
resourceId: input.invitationId,
resourceName: invitationResult.email,
metadata: { type: "removeInvitation" },
});
return result;
}), }),
updateMemberRole: adminProcedure updateMemberRole: withPermission("member", "update")
.input( .input(
z.object({ z.object({
memberId: z.string(), memberId: z.string(),
role: z.enum(["admin", "member"]), role: z.string().min(1),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -289,7 +410,7 @@ export const organizationRouter = createTRPCRouter({
} }
// Owner role is intransferible - cannot change to or from owner // Owner role is intransferible - cannot change to or from owner
if (target.role === "owner") { if (target.role === "owner" || input.role === "owner") {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "The owner role is intransferible", message: "The owner role is intransferible",
@@ -306,12 +427,39 @@ export const organizationRouter = createTRPCRouter({
}); });
} }
// If assigning a custom role (not admin/member), verify it exists
if (input.role !== "admin" && input.role !== "member") {
const customRole = await db.query.organizationRole.findFirst({
where: and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.role),
),
});
if (!customRole) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Custom role "${input.role}" not found`,
});
}
}
// Update the target member's role // Update the target member's role
await db await db
.update(member) .update(member)
.set({ role: input.role }) .set({ role: input.role })
.where(eq(member.id, input.memberId)); .where(eq(member.id, input.memberId));
await audit(ctx, {
action: "update",
resourceType: "user",
resourceId: target.userId,
resourceName: target.user.email,
metadata: { before: target.role, after: input.role },
});
return true; return true;
}), }),
setDefault: protectedProcedure setDefault: protectedProcedure
@@ -353,6 +501,12 @@ export const organizationRouter = createTRPCRouter({
), ),
); );
await audit(ctx, {
action: "update",
resourceType: "organization",
resourceId: input.organizationId,
metadata: { type: "setDefault" },
});
return { success: true }; return { success: true };
}), }),
active: protectedProcedure.query(async ({ ctx }) => { active: protectedProcedure.query(async ({ ctx }) => {

View File

@@ -1,5 +1,4 @@
import { import {
checkServiceAccess,
cleanPatchRepos, cleanPatchRepos,
createPatch, createPatch,
deletePatch, deletePatch,
@@ -14,6 +13,7 @@ import {
readPatchRepoFile, readPatchRepoFile,
updatePatch, updatePatch,
} from "@dokploy/server"; } from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -21,6 +21,7 @@ import {
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { import {
apiCreatePatch, apiCreatePatch,
apiDeletePatch, apiDeletePatch,
@@ -29,47 +30,56 @@ import {
apiUpdatePatch, apiUpdatePatch,
} from "@/server/db/schema"; } from "@/server/db/schema";
/**
* Resolves the serviceId from a patch record (applicationId or composeId).
* Throws if neither is set.
*/
const resolvePatchServiceId = (patch: {
applicationId: string | null;
composeId: string | null;
}): string => {
const serviceId = patch.applicationId ?? patch.composeId;
if (!serviceId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Patch has no associated service",
});
}
return serviceId;
};
export const patchRouter = createTRPCRouter({ export const patchRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreatePatch) .input(apiCreatePatch)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (input.applicationId) { const serviceId = input.applicationId ?? input.composeId;
const app = await findApplicationById(input.applicationId); if (!serviceId) {
if ( throw new TRPCError({
app.environment.project.organizationId !== code: "BAD_REQUEST",
ctx.session.activeOrganizationId message: "Either applicationId or composeId must be provided",
) { });
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
if (ctx.user.role === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
ctx.session.activeOrganizationId,
"access",
);
}
} else if (input.composeId) {
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 access this compose",
});
}
} }
await checkServicePermissionAndAccess(ctx, serviceId, {
return await createPatch(input); service: ["create"],
});
const result = await createPatch(input);
await audit(ctx, {
action: "create",
resourceType: "settings",
resourceId: result.patchId,
resourceName: result.filePath,
metadata: { type: "patch" },
});
return result;
}), }),
one: protectedProcedure.input(apiFindPatch).query(async ({ input }) => { one: protectedProcedure.input(apiFindPatch).query(async ({ input, ctx }) => {
return await findPatchById(input.patchId); const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["read"],
});
return patch;
}), }),
byEntityId: protectedProcedure byEntityId: protectedProcedure
@@ -77,51 +87,70 @@ export const patchRouter = createTRPCRouter({
z.object({ id: z.string(), type: z.enum(["application", "compose"]) }), z.object({ id: z.string(), type: z.enum(["application", "compose"]) }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (input.type === "application") { await checkServicePermissionAndAccess(ctx, input.id, {
const app = await findApplicationById(input.id); service: ["read"],
if ( });
app.environment.project.organizationId !== return await findPatchesByEntityId(input.id, input.type);
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
const result = await findPatchesByEntityId(input.id, input.type);
return result;
}), }),
update: protectedProcedure update: protectedProcedure
.input(apiUpdatePatch) .input(apiUpdatePatch)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["create"],
});
const { patchId, ...data } = input; const { patchId, ...data } = input;
return await updatePatch(patchId, data); const result = await updatePatch(patchId, data);
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceId: patchId,
resourceName: patch.filePath,
metadata: { type: "patch" },
});
return result;
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(apiDeletePatch) .input(apiDeletePatch)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
return await deletePatch(input.patchId); const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["delete"],
});
const result = await deletePatch(input.patchId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceId: input.patchId,
resourceName: patch.filePath,
metadata: { type: "patch" },
});
return result;
}), }),
toggleEnabled: protectedProcedure toggleEnabled: protectedProcedure
.input(apiTogglePatchEnabled) .input(apiTogglePatchEnabled)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
return await updatePatch(input.patchId, { enabled: input.enabled }); const patch = await findPatchById(input.patchId);
const serviceId = resolvePatchServiceId(patch);
await checkServicePermissionAndAccess(ctx, serviceId, {
service: ["create"],
});
const result = await updatePatch(input.patchId, {
enabled: input.enabled,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceId: input.patchId,
resourceName: patch.filePath,
metadata: { type: "patch", enabled: input.enabled },
});
return result;
}), }),
// Repository Operations // Repository Operations
@@ -132,11 +161,21 @@ export const patchRouter = createTRPCRouter({
type: z.enum(["application", "compose"]), type: z.enum(["application", "compose"]),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
return await ensurePatchRepo({ await checkServicePermissionAndAccess(ctx, input.id, {
service: ["create"],
});
const result = await ensurePatchRepo({
type: input.type, type: input.type,
id: input.id, id: input.id,
}); });
await audit(ctx, {
action: "create",
resourceType: "settings",
resourceId: input.id,
metadata: { type: "ensurePatchRepo", serviceType: input.type },
});
return result;
}), }),
readRepoDirectories: protectedProcedure readRepoDirectories: protectedProcedure
@@ -148,36 +187,17 @@ export const patchRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["read"],
});
let serverId: string | null = null; let serverId: string | null = null;
if (input.type === "application") { if (input.type === "application") {
const app = await findApplicationById(input.id); const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId; serverId = app.serverId;
} } else {
if (input.type === "compose") {
const compose = await findComposeById(input.id); const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId; serverId = compose.serverId;
} }
return await readPatchRepoDirectory(input.repoPath, serverId); return await readPatchRepoDirectory(input.repoPath, serverId);
}), }),
@@ -190,44 +210,22 @@ export const patchRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.id, {
service: ["read"],
});
let serverId: string | null = null; let serverId: string | null = null;
if (input.type === "application") { if (input.type === "application") {
const app = await findApplicationById(input.id); const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
serverId = app.serverId; serverId = app.serverId;
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
serverId = compose.serverId;
} else { } else {
throw new TRPCError({ const compose = await findComposeById(input.id);
code: "BAD_REQUEST", serverId = compose.serverId;
message: "Either applicationId or composeId must be provided",
});
} }
const existingPatch = await findPatchByFilePath( const existingPatch = await findPatchByFilePath(
input.filePath, input.filePath,
input.id, input.id,
input.type, input.type,
); );
// For delete patches, show current file content from repo (what will be deleted) // For delete patches, show current file content from repo (what will be deleted)
if (existingPatch?.type === "delete") { if (existingPatch?.type === "delete") {
try { try {
@@ -253,55 +251,43 @@ export const patchRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (input.type === "application") { await checkServicePermissionAndAccess(ctx, input.id, {
const app = await findApplicationById(input.id); service: ["create"],
if ( });
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
} else if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either application or compose must be provided",
});
}
const existingPatch = await findPatchByFilePath( const existingPatch = await findPatchByFilePath(
input.filePath, input.filePath,
input.id, input.id,
input.type, input.type,
); );
if (!existingPatch) { if (!existingPatch) {
return await createPatch({ const result = await createPatch({
filePath: input.filePath, filePath: input.filePath,
content: input.content, content: input.content,
type: input.patchType, type: input.patchType,
applicationId: input.type === "application" ? input.id : undefined, applicationId: input.type === "application" ? input.id : undefined,
composeId: input.type === "compose" ? input.id : undefined, composeId: input.type === "compose" ? input.id : undefined,
}); });
await audit(ctx, {
action: "create",
resourceType: "settings",
resourceId: result.patchId,
resourceName: input.filePath,
metadata: { type: "saveFileAsPatch" },
});
return result;
} }
const result = await updatePatch(existingPatch.patchId, {
return await updatePatch(existingPatch.patchId, {
content: input.content, content: input.content,
type: input.patchType, type: input.patchType,
}); });
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceId: existingPatch.patchId,
resourceName: input.filePath,
metadata: { type: "saveFileAsPatch" },
});
return result;
}), }),
markFileForDeletion: protectedProcedure markFileForDeletion: protectedProcedure
@@ -313,36 +299,34 @@ export const patchRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (input.type === "application") { await checkServicePermissionAndAccess(ctx, input.id, {
const app = await findApplicationById(input.id); service: ["create"],
if ( });
app.environment.project.organizationId !== const result = await markPatchForDeletion(
ctx.session.activeOrganizationId input.filePath,
) { input.id,
throw new TRPCError({ input.type,
code: "UNAUTHORIZED", );
message: "You are not authorized to access this application", await audit(ctx, {
}); action: "delete",
} resourceType: "settings",
} else if (input.type === "compose") { resourceId: input.id,
const compose = await findComposeById(input.id); resourceName: input.filePath,
if ( metadata: { type: "markFileForDeletion" },
compose.environment.project.organizationId !== });
ctx.session.activeOrganizationId return result;
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
}
return await markPatchForDeletion(input.filePath, input.id, input.type);
}), }),
cleanPatchRepos: adminProcedure cleanPatchRepos: adminProcedure
.input(z.object({ serverId: z.string().optional() })) .input(z.object({ serverId: z.string().optional() }))
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
await cleanPatchRepos(input.serverId); await cleanPatchRepos(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "settings",
resourceId: input.serverId || "local",
metadata: { type: "cleanPatchRepos" },
});
return true; return true;
}), }),
}); });

View File

@@ -4,8 +4,10 @@ import {
removePortById, removePortById,
updatePortById, updatePortById,
} from "@dokploy/server"; } from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { import {
apiCreatePort, apiCreatePort,
apiFindOnePort, apiFindOnePort,
@@ -15,10 +17,19 @@ import {
export const portRouter = createTRPCRouter({ export const portRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreatePort) .input(apiCreatePort)
.mutation(async ({ input }) => { .mutation(async ({ input, ctx }) => {
try { try {
await createPort(input); await checkServicePermissionAndAccess(ctx, input.applicationId, {
return true; service: ["create"],
});
const port = await createPort(input);
await audit(ctx, {
action: "create",
resourceType: "port",
resourceId: port.portId,
resourceName: `${port.publishedPort}:${port.targetPort}`,
});
return port;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -32,15 +43,11 @@ export const portRouter = createTRPCRouter({
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
try { try {
const port = await finPortById(input.portId); const port = await finPortById(input.portId);
if ( await checkServicePermissionAndAccess(
port.application.environment.project.organizationId !== ctx,
ctx.session.activeOrganizationId port.application.applicationId,
) { { service: ["read"] },
throw new TRPCError({ );
code: "UNAUTHORIZED",
message: "You are not authorized to access this port",
});
}
return port; return port;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -54,17 +61,20 @@ export const portRouter = createTRPCRouter({
.input(apiFindOnePort) .input(apiFindOnePort)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const port = await finPortById(input.portId); const port = await finPortById(input.portId);
if ( await checkServicePermissionAndAccess(
port.application.environment.project.organizationId !== ctx,
ctx.session.activeOrganizationId port.application.applicationId,
) { { service: ["delete"] },
throw new TRPCError({ );
code: "UNAUTHORIZED",
message: "You are not authorized to delete this port",
});
}
try { try {
return await removePortById(input.portId); const result = await removePortById(input.portId);
await audit(ctx, {
action: "delete",
resourceType: "port",
resourceId: port.portId,
resourceName: `${port.publishedPort}:${port.targetPort}`,
});
return result;
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Error input: Deleting port"; error instanceof Error ? error.message : "Error input: Deleting port";
@@ -78,17 +88,20 @@ export const portRouter = createTRPCRouter({
.input(apiUpdatePort) .input(apiUpdatePort)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const port = await finPortById(input.portId); const port = await finPortById(input.portId);
if ( await checkServicePermissionAndAccess(
port.application.environment.project.organizationId !== ctx,
ctx.session.activeOrganizationId port.application.applicationId,
) { { service: ["create"] },
throw new TRPCError({ );
code: "UNAUTHORIZED",
message: "You are not authorized to update this port",
});
}
try { try {
return await updatePortById(input.portId, input); const result = await updatePortById(input.portId, input);
await audit(ctx, {
action: "update",
resourceType: "port",
resourceId: port.portId,
resourceName: `${port.publishedPort}:${port.targetPort}`,
});
return result;
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Error updating the port"; error instanceof Error ? error.message : "Error updating the port";

View File

@@ -1,13 +1,10 @@
import { import {
addNewService,
checkPortInUse, checkPortInUse,
checkServiceAccess,
createMount, createMount,
createPostgres, createPostgres,
deployPostgres, deployPostgres,
findBackupsByDbId, findBackupsByDbId,
findEnvironmentById, findEnvironmentById,
findMemberById,
findPostgresById, findPostgresById,
findProjectById, findProjectById,
getMountPath, getMountPath,
@@ -21,10 +18,17 @@ import {
stopServiceRemote, stopServiceRemote,
updatePostgresById, updatePostgresById,
} from "@dokploy/server"; } from "@dokploy/server";
import {
addNewService,
checkServiceAccess,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { import {
apiChangePostgresStatus, apiChangePostgresStatus,
@@ -46,18 +50,10 @@ export const postgresRouter = createTRPCRouter({
.input(apiCreatePostgres) .input(apiCreatePostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
// Get project from environment
const environment = await findEnvironmentById(input.environmentId); const environment = await findEnvironmentById(input.environmentId);
const project = await findProjectById(environment.projectId); const project = await findProjectById(environment.projectId);
if (ctx.user.role === "member") { await checkServiceAccess(ctx, project.projectId, "create");
await checkServiceAccess(
ctx.user.id,
project.projectId,
ctx.session.activeOrganizationId,
"create",
);
}
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -75,13 +71,7 @@ export const postgresRouter = createTRPCRouter({
const newPostgres = await createPostgres({ const newPostgres = await createPostgres({
...input, ...input,
}); });
if (ctx.user.role === "member") { await addNewService(ctx, newPostgres.postgresId);
await addNewService(
ctx.user.id,
newPostgres.postgresId,
project.organizationId,
);
}
const mountPath = getMountPath(input.dockerImage); const mountPath = getMountPath(input.dockerImage);
@@ -93,6 +83,12 @@ export const postgresRouter = createTRPCRouter({
type: "volume", type: "volume",
}); });
await audit(ctx, {
action: "create",
resourceType: "service",
resourceId: newPostgres.postgresId,
resourceName: newPostgres.appName,
});
return newPostgres; return newPostgres;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
@@ -108,14 +104,7 @@ export const postgresRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure
.input(apiFindOnePostgres) .input(apiFindOnePostgres)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.postgresId, "read");
await checkServiceAccess(
ctx.user.id,
input.postgresId,
ctx.session.activeOrganizationId,
"access",
);
}
const postgres = await findPostgresById(input.postgresId); const postgres = await findPostgresById(input.postgresId);
if ( if (
@@ -133,18 +122,11 @@ export const postgresRouter = createTRPCRouter({
start: protectedProcedure start: protectedProcedure
.input(apiFindOnePostgres) .input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const service = await findPostgresById(input.postgresId); const service = await findPostgresById(input.postgresId);
if (
service.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to start this Postgres",
});
}
if (service.serverId) { if (service.serverId) {
await startServiceRemote(service.serverId, service.appName); await startServiceRemote(service.serverId, service.appName);
} else { } else {
@@ -154,21 +136,21 @@ export const postgresRouter = createTRPCRouter({
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "start",
resourceType: "service",
resourceId: service.postgresId,
resourceName: service.appName,
});
return service; return service;
}), }),
stop: protectedProcedure stop: protectedProcedure
.input(apiFindOnePostgres) .input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId); const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to stop this Postgres",
});
}
if (postgres.serverId) { if (postgres.serverId) {
await stopServiceRemote(postgres.serverId, postgres.appName); await stopServiceRemote(postgres.serverId, postgres.appName);
} else { } else {
@@ -178,23 +160,22 @@ export const postgresRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
}); });
await audit(ctx, {
action: "stop",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return postgres; return postgres;
}), }),
saveExternalPort: protectedProcedure saveExternalPort: protectedProcedure
.input(apiSaveExternalPortPostgres) .input(apiSaveExternalPortPostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["create"],
});
const postgres = await findPostgresById(input.postgresId); const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this external port",
});
}
if (input.externalPort) { if (input.externalPort) {
const portCheck = await checkPortInUse( const portCheck = await checkPortInUse(
input.externalPort, input.externalPort,
@@ -212,21 +193,27 @@ export const postgresRouter = createTRPCRouter({
externalPort: input.externalPort, externalPort: input.externalPort,
}); });
await deployPostgres(input.postgresId); await deployPostgres(input.postgresId);
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return postgres; return postgres;
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiDeployPostgres) .input(apiDeployPostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId); const postgres = await findPostgresById(input.postgresId);
if ( await audit(ctx, {
postgres.environment.project.organizationId !== action: "deploy",
ctx.session.activeOrganizationId resourceType: "service",
) { resourceId: postgres.postgresId,
throw new TRPCError({ resourceName: postgres.appName,
code: "UNAUTHORIZED", });
message: "You are not authorized to deploy this Postgres",
});
}
return deployPostgres(input.postgresId); return deployPostgres(input.postgresId);
}), }),
@@ -241,17 +228,9 @@ export const postgresRouter = createTRPCRouter({
}) })
.input(apiDeployPostgres) .input(apiDeployPostgres)
.subscription(async function* ({ input, ctx, signal }) { .subscription(async function* ({ input, ctx, signal }) {
const postgres = await findPostgresById(input.postgresId); await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
if ( });
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to deploy this Postgres",
});
}
const queue: string[] = []; const queue: string[] = [];
const done = false; const done = false;
@@ -276,32 +255,25 @@ export const postgresRouter = createTRPCRouter({
changeStatus: protectedProcedure changeStatus: protectedProcedure
.input(apiChangePostgresStatus) .input(apiChangePostgresStatus)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId); const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to change this Postgres status",
});
}
await updatePostgresById(input.postgresId, { await updatePostgresById(input.postgresId, {
applicationStatus: input.applicationStatus, applicationStatus: input.applicationStatus,
}); });
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return postgres; return postgres;
}), }),
remove: protectedProcedure remove: protectedProcedure
.input(apiFindOnePostgres) .input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") { await checkServiceAccess(ctx, input.postgresId, "delete");
await checkServiceAccess(
ctx.user.id,
input.postgresId,
ctx.session.activeOrganizationId,
"delete",
);
}
const postgres = await findPostgresById(input.postgresId); const postgres = await findPostgresById(input.postgresId);
if ( if (
@@ -314,6 +286,12 @@ export const postgresRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "delete",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
const backups = await findBackupsByDbId(input.postgresId, "postgres"); const backups = await findBackupsByDbId(input.postgresId, "postgres");
const cleanupOperations = [ const cleanupOperations = [
@@ -333,16 +311,9 @@ export const postgresRouter = createTRPCRouter({
saveEnvironment: protectedProcedure saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesPostgres) .input(apiSaveEnvironmentVariablesPostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId); await checkServicePermissionAndAccess(ctx, input.postgresId, {
if ( envVars: ["write"],
postgres.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to save this environment",
});
}
const service = await updatePostgresById(input.postgresId, { const service = await updatePostgresById(input.postgresId, {
env: input.env, env: input.env,
}); });
@@ -354,21 +325,20 @@ export const postgresRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: input.postgresId,
});
return true; return true;
}), }),
reload: protectedProcedure reload: protectedProcedure
.input(apiResetPostgres) .input(apiResetPostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.postgresId, {
deployment: ["create"],
});
const postgres = await findPostgresById(input.postgresId); const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this Postgres",
});
}
if (postgres.serverId) { if (postgres.serverId) {
await stopServiceRemote(postgres.serverId, postgres.appName); await stopServiceRemote(postgres.serverId, postgres.appName);
} else { } else {
@@ -386,22 +356,21 @@ export const postgresRouter = createTRPCRouter({
await updatePostgresById(input.postgresId, { await updatePostgresById(input.postgresId, {
applicationStatus: "done", applicationStatus: "done",
}); });
await audit(ctx, {
action: "reload",
resourceType: "service",
resourceId: postgres.postgresId,
resourceName: postgres.appName,
});
return true; return true;
}), }),
update: protectedProcedure update: protectedProcedure
.input(apiUpdatePostgres) .input(apiUpdatePostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { postgresId, ...rest } = input; const { postgresId, ...rest } = input;
const postgres = await findPostgresById(postgresId); await checkServicePermissionAndAccess(ctx, postgresId, {
if ( service: ["create"],
postgres.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this Postgres",
});
}
const service = await updatePostgresById(postgresId, { const service = await updatePostgresById(postgresId, {
...rest, ...rest,
@@ -414,6 +383,12 @@ export const postgresRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "update",
resourceType: "service",
resourceId: postgresId,
resourceName: service.appName,
});
return true; return true;
}), }),
move: protectedProcedure move: protectedProcedure
@@ -424,31 +399,10 @@ export const postgresRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId); await checkServicePermissionAndAccess(ctx, input.postgresId, {
if ( service: ["create"],
postgres.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this postgres",
});
}
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 postgres's projectId
const updatedPostgres = await db const updatedPostgres = await db
.update(postgresTable) .update(postgresTable)
.set({ .set({
@@ -465,24 +419,28 @@ export const postgresRouter = createTRPCRouter({
}); });
} }
await audit(ctx, {
action: "move",
resourceType: "service",
resourceId: updatedPostgres.postgresId,
resourceName: updatedPostgres.appName,
});
return updatedPostgres; return updatedPostgres;
}), }),
rebuild: protectedProcedure rebuild: protectedProcedure
.input(apiRebuildPostgres) .input(apiRebuildPostgres)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId); await checkServicePermissionAndAccess(ctx, input.postgresId, {
if ( deployment: ["create"],
postgres.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to rebuild this Postgres database",
});
}
await rebuildDatabase(postgres.postgresId, "postgres"); await rebuildDatabase(input.postgresId, "postgres");
await audit(ctx, {
action: "rebuild",
resourceType: "service",
resourceId: input.postgresId,
});
return true; return true;
}), }),
search: protectedProcedure search: protectedProcedure
@@ -538,19 +496,18 @@ export const postgresRouter = createTRPCRouter({
), ),
); );
} }
if (ctx.user.role === "member") { const { accessedServices } = await findMemberByUserId(
const { accessedServices } = await findMemberById( ctx.user.id,
ctx.user.id, ctx.session.activeOrganizationId,
ctx.session.activeOrganizationId, );
); if (accessedServices.length === 0) return { items: [], total: 0 };
if (accessedServices.length === 0) return { items: [], total: 0 }; baseConditions.push(
baseConditions.push( sql`${postgresTable.postgresId} IN (${sql.join(
sql`${postgresTable.postgresId} IN (${sql.join( accessedServices.map((id) => sql`${id}`),
accessedServices.map((id) => sql`${id}`), sql`, `,
sql`, `, )})`,
)})`, );
);
}
const where = and(...baseConditions); const where = and(...baseConditions);
const [items, countResult] = await Promise.all([ const [items, countResult] = await Promise.all([
db db

View File

@@ -5,8 +5,9 @@ import {
IS_CLOUD, IS_CLOUD,
removePreviewDeployment, removePreviewDeployment,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { apiFindAllByApplication } from "@/server/db/schema"; import { apiFindAllByApplication } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup"; import { myQueue } from "@/server/queues/queueSetup";
@@ -17,53 +18,46 @@ export const previewDeploymentRouter = createTRPCRouter({
all: protectedProcedure all: protectedProcedure
.input(apiFindAllByApplication) .input(apiFindAllByApplication)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); await checkServicePermissionAndAccess(ctx, input.applicationId, {
if ( deployment: ["read"],
application.environment.project.organizationId !== });
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await findPreviewDeploymentsByApplicationId(input.applicationId); return await findPreviewDeploymentsByApplicationId(input.applicationId);
}), }),
delete: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() }))
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (
previewDeployment.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this preview deployment",
});
}
await removePreviewDeployment(input.previewDeploymentId);
return true;
}),
one: protectedProcedure one: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() })) .input(z.object({ previewDeploymentId: z.string() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById( const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId, input.previewDeploymentId,
); );
if ( await checkServicePermissionAndAccess(
previewDeployment.application.environment.project.organizationId !== ctx,
ctx.session.activeOrganizationId previewDeployment.applicationId,
) { { deployment: ["read"] },
throw new TRPCError({ );
code: "UNAUTHORIZED",
message: "You are not authorized to access this preview deployment",
});
}
return previewDeployment; return previewDeployment;
}), }),
delete: protectedProcedure
.input(z.object({ previewDeploymentId: z.string() }))
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
await checkServicePermissionAndAccess(
ctx,
previewDeployment.applicationId,
{ deployment: ["cancel"] },
);
await removePreviewDeployment(input.previewDeploymentId);
await audit(ctx, {
action: "delete",
resourceType: "previewDeployment",
resourceId: input.previewDeploymentId,
});
return true;
}),
redeploy: protectedProcedure redeploy: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -76,15 +70,11 @@ export const previewDeploymentRouter = createTRPCRouter({
const previewDeployment = await findPreviewDeploymentById( const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId, input.previewDeploymentId,
); );
if ( await checkServicePermissionAndAccess(
previewDeployment.application.environment.project.organizationId !== ctx,
ctx.session.activeOrganizationId previewDeployment.applicationId,
) { { deployment: ["create"] },
throw new TRPCError({ );
code: "UNAUTHORIZED",
message: "You are not authorized to redeploy this preview deployment",
});
}
const application = await findApplicationById( const application = await findApplicationById(
previewDeployment.applicationId, previewDeployment.applicationId,
); );
@@ -103,6 +93,11 @@ export const previewDeploymentRouter = createTRPCRouter({
deploy(jobData).catch((error) => { deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error); console.error("Background deployment failed:", error);
}); });
await audit(ctx, {
action: "redeploy",
resourceType: "previewDeployment",
resourceId: input.previewDeploymentId,
});
return true; return true;
} }
await myQueue.add( await myQueue.add(
@@ -113,6 +108,11 @@ export const previewDeploymentRouter = createTRPCRouter({
removeOnFail: true, removeOnFail: true,
}, },
); );
await audit(ctx, {
action: "redeploy",
resourceType: "previewDeployment",
resourceId: input.previewDeploymentId,
});
return true; return true;
}), }),
}); });

View File

@@ -1,7 +1,4 @@
import { import {
addNewEnvironment,
addNewProject,
checkProjectAccess,
createApplication, createApplication,
createBackup, createBackup,
createCompose, createCompose,
@@ -22,7 +19,6 @@ import {
findComposeById, findComposeById,
findEnvironmentById, findEnvironmentById,
findMariadbById, findMariadbById,
findMemberById,
findMongoById, findMongoById,
findMySqlById, findMySqlById,
findPostgresById, findPostgresById,
@@ -32,15 +28,23 @@ import {
IS_CLOUD, IS_CLOUD,
updateProjectById, updateProjectById,
} from "@dokploy/server"; } from "@dokploy/server";
import {
addNewEnvironment,
addNewProject,
checkPermission,
checkProjectAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core"; import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { z } from "zod"; import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { import {
adminProcedure,
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
withPermission,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { import {
apiCreateProject, apiCreateProject,
@@ -63,13 +67,7 @@ export const projectRouter = createTRPCRouter({
.input(apiCreateProject) .input(apiCreateProject)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
if (ctx.user.role === "member") { await checkProjectAccess(ctx, "create");
await checkProjectAccess(
ctx.user.id,
"create",
ctx.session.activeOrganizationId,
);
}
const admin = await findUserById(ctx.user.ownerId); const admin = await findUserById(ctx.user.ownerId);
@@ -84,20 +82,16 @@ export const projectRouter = createTRPCRouter({
input, input,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
if (ctx.user.role === "member") { await addNewProject(ctx, project.project.projectId);
await addNewProject(
ctx.user.id,
project.project.projectId,
ctx.session.activeOrganizationId,
);
await addNewEnvironment( await addNewEnvironment(ctx, project?.environment?.environmentId || "");
ctx.user.id,
project?.environment?.environmentId || "",
ctx.session.activeOrganizationId,
);
}
await audit(ctx, {
action: "create",
resourceType: "project",
resourceId: project.project.projectId,
resourceName: project.project.name,
});
return project; return project;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -111,18 +105,18 @@ export const projectRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure
.input(apiFindOneProject) .input(apiFindOneProject)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
if (ctx.user.role === "member") { if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedServices } = await findMemberById( const { accessedServices, accessedProjects } = await findMemberByUserId(
ctx.user.id, ctx.user.id,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
await checkProjectAccess( if (!accessedProjects.includes(input.projectId)) {
ctx.user.id, throw new TRPCError({
"access", code: "UNAUTHORIZED",
ctx.session.activeOrganizationId, message: "You don't have access to this project",
input.projectId, });
); }
const project = await db.query.projects.findFirst({ const project = await db.query.projects.findFirst({
where: and( where: and(
@@ -189,15 +183,14 @@ export const projectRouter = createTRPCRouter({
return project; return project;
}), }),
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role === "member") { if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedProjects, accessedEnvironments, accessedServices } = const { accessedProjects, accessedEnvironments, accessedServices } =
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId); await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
if (accessedProjects.length === 0) { if (accessedProjects.length === 0) {
return []; return [];
} }
// Build environment filter
const environmentFilter = const environmentFilter =
accessedEnvironments.length === 0 accessedEnvironments.length === 0
? sql`false` ? sql`false`
@@ -348,105 +341,106 @@ export const projectRouter = createTRPCRouter({
}); });
}), }),
/** All projects with full environments and services for the admin permissions UI. Admin only. */ allForPermissions: withPermission("member", "update").query(
allForPermissions: adminProcedure.query(async ({ ctx }) => { async ({ ctx }) => {
return await db.query.projects.findMany({ return await db.query.projects.findMany({
where: eq(projects.organizationId, ctx.session.activeOrganizationId), where: eq(projects.organizationId, ctx.session.activeOrganizationId),
orderBy: desc(projects.createdAt), orderBy: desc(projects.createdAt),
columns: { columns: {
projectId: true, projectId: true,
name: true, name: true,
}, },
with: { with: {
environments: { environments: {
columns: { columns: {
environmentId: true, environmentId: true,
name: true, name: true,
isDefault: true, isDefault: true,
},
with: {
applications: {
columns: {
applicationId: true,
appName: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
}, },
mariadb: { with: {
columns: { applications: {
mariadbId: true, columns: {
appName: true, applicationId: true,
name: true, appName: true,
createdAt: true, name: true,
applicationStatus: true, createdAt: true,
description: true, applicationStatus: true,
serverId: true, description: true,
serverId: true,
},
}, },
}, mariadb: {
postgres: { columns: {
columns: { mariadbId: true,
postgresId: true, appName: true,
appName: true, name: true,
name: true, createdAt: true,
createdAt: true, applicationStatus: true,
applicationStatus: true, description: true,
description: true, serverId: true,
serverId: true, },
}, },
}, postgres: {
mysql: { columns: {
columns: { postgresId: true,
mysqlId: true, appName: true,
appName: true, name: true,
name: true, createdAt: true,
createdAt: true, applicationStatus: true,
applicationStatus: true, description: true,
description: true, serverId: true,
serverId: true, },
}, },
}, mysql: {
mongo: { columns: {
columns: { mysqlId: true,
mongoId: true, appName: true,
appName: true, name: true,
name: true, createdAt: true,
createdAt: true, applicationStatus: true,
applicationStatus: true, description: true,
description: true, serverId: true,
serverId: true, },
}, },
}, mongo: {
redis: { columns: {
columns: { mongoId: true,
redisId: true, appName: true,
appName: true, name: true,
name: true, createdAt: true,
createdAt: true, applicationStatus: true,
applicationStatus: true, description: true,
description: true, serverId: true,
serverId: true, },
}, },
}, redis: {
compose: { columns: {
columns: { redisId: true,
composeId: true, appName: true,
appName: true, name: true,
name: true, createdAt: true,
createdAt: true, applicationStatus: true,
composeStatus: true, description: true,
description: true, serverId: true,
serverId: true, },
},
compose: {
columns: {
composeId: true,
appName: true,
name: true,
createdAt: true,
composeStatus: true,
description: true,
serverId: true,
},
}, },
}, },
}, },
}, },
}, });
}); },
}), ),
search: protectedProcedure search: protectedProcedure
.input( .input(
@@ -482,8 +476,8 @@ export const projectRouter = createTRPCRouter({
); );
} }
if (ctx.user.role === "member") { if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedProjects } = await findMemberById( const { accessedProjects } = await findMemberByUserId(
ctx.user.id, ctx.user.id,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
@@ -529,13 +523,6 @@ export const projectRouter = createTRPCRouter({
.input(apiRemoveProject) .input(apiRemoveProject)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
if (ctx.user.role === "member") {
await checkProjectAccess(
ctx.user.id,
"delete",
ctx.session.activeOrganizationId,
);
}
const currentProject = await findProjectById(input.projectId); const currentProject = await findProjectById(input.projectId);
if ( if (
currentProject.organizationId !== ctx.session.activeOrganizationId currentProject.organizationId !== ctx.session.activeOrganizationId
@@ -545,8 +532,15 @@ export const projectRouter = createTRPCRouter({
message: "You are not authorized to delete this project", message: "You are not authorized to delete this project",
}); });
} }
await checkProjectAccess(ctx, "delete", input.projectId);
const deletedProject = await deleteProject(input.projectId); const deletedProject = await deleteProject(input.projectId);
await audit(ctx, {
action: "delete",
resourceType: "project",
resourceId: currentProject.projectId,
resourceName: currentProject.name,
});
return deletedProject; return deletedProject;
} catch (error) { } catch (error) {
throw error; throw error;
@@ -565,10 +559,36 @@ export const projectRouter = createTRPCRouter({
message: "You are not authorized to update this project", message: "You are not authorized to update this project",
}); });
} }
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
const { accessedProjects } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!accessedProjects.includes(input.projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
if (input.env !== undefined) {
await checkPermission(ctx, { projectEnvVars: ["write"] });
}
const project = await updateProjectById(input.projectId, { const project = await updateProjectById(input.projectId, {
...input, ...input,
}); });
if (project) {
await audit(ctx, {
action: "update",
resourceType: "project",
resourceId: input.projectId,
resourceName: project.name,
});
}
return project; return project;
} catch (error) { } catch (error) {
throw error; throw error;
@@ -602,15 +622,8 @@ export const projectRouter = createTRPCRouter({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
if (ctx.user.role === "member") { await checkProjectAccess(ctx, "create");
await checkProjectAccess(
ctx.user.id,
"create",
ctx.session.activeOrganizationId,
);
}
// Get source project
const sourceEnvironment = input.duplicateInSameProject const sourceEnvironment = input.duplicateInSameProject
? await findEnvironmentById(input.sourceEnvironmentId) ? await findEnvironmentById(input.sourceEnvironmentId)
: null; : null;
@@ -626,7 +639,24 @@ export const projectRouter = createTRPCRouter({
}); });
} }
// Create new project or use existing one if (
input.duplicateInSameProject &&
sourceEnvironment &&
ctx.user.role !== "owner" &&
ctx.user.role !== "admin"
) {
const { accessedProjects } = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!accessedProjects.includes(sourceEnvironment.project.projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
const targetProject = input.duplicateInSameProject const targetProject = input.duplicateInSameProject
? sourceEnvironment ? sourceEnvironment
: await createProject( : await createProject(
@@ -643,7 +673,6 @@ export const projectRouter = createTRPCRouter({
if (input.includeServices) { if (input.includeServices) {
const servicesToDuplicate = input.selectedServices || []; const servicesToDuplicate = input.selectedServices || [];
// Helper function to duplicate a service
const duplicateService = async (id: string, type: string) => { const duplicateService = async (id: string, type: string) => {
switch (type) { switch (type) {
case "application": { case "application": {
@@ -947,20 +976,22 @@ export const projectRouter = createTRPCRouter({
} }
}; };
// Duplicate selected services
for (const service of servicesToDuplicate) { for (const service of servicesToDuplicate) {
await duplicateService(service.id, service.type); await duplicateService(service.id, service.type);
} }
} }
if (!input.duplicateInSameProject && ctx.user.role === "member") { if (!input.duplicateInSameProject) {
await addNewProject( await addNewProject(ctx, targetProject?.projectId || "");
ctx.user.id,
targetProject?.projectId || "",
ctx.session.activeOrganizationId,
);
} }
await audit(ctx, {
action: "create",
resourceType: "project",
resourceId: targetProject?.projectId || "",
resourceName: input.name,
metadata: { duplicatedFrom: input.sourceEnvironmentId },
});
return targetProject; return targetProject;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -0,0 +1,67 @@
import { getAuditLogs } from "@dokploy/server/services/proprietary/audit-log";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, withPermission } from "../../trpc";
export const auditLogRouter = createTRPCRouter({
all: withPermission("auditLog", "read")
.use(async ({ ctx, next }) => {
const licensed = await hasValidLicense(ctx.session.activeOrganizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Valid enterprise license required",
});
}
return next();
})
.input(
z.object({
userId: z.string().optional(),
userEmail: z.string().optional(),
resourceName: z.string().optional(),
action: z
.enum([
"create",
"update",
"delete",
"deploy",
"cancel",
"redeploy",
"login",
"logout",
])
.optional(),
resourceType: z
.enum([
"project",
"service",
"environment",
"deployment",
"user",
"customRole",
"domain",
"certificate",
"registry",
"server",
"sshKey",
"gitProvider",
"notification",
"settings",
"session",
])
.optional(),
from: z.date().optional(),
to: z.date().optional(),
limit: z.number().min(1).max(500).default(50),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
return getAuditLogs({
organizationId: ctx.session.activeOrganizationId,
...input,
});
}),
});

View File

@@ -0,0 +1,321 @@
import { db } from "@dokploy/server/db";
import { member, organizationRole, user } from "@dokploy/server/db/schema";
import { statements } from "@dokploy/server/lib/access-control";
import { TRPCError } from "@trpc/server";
import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
import {
createTRPCRouter,
enterpriseProcedure,
protectedProcedure,
} from "../../trpc";
import { audit } from "../../utils/audit";
const permissionsSchema = z.record(z.string(), z.array(z.string()));
export const customRoleRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
const [roles, memberCounts] = await Promise.all([
db.query.organizationRole.findMany({
where: eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
}),
db
.select({ role: member.role, count: count() })
.from(member)
.where(eq(member.organizationId, ctx.session.activeOrganizationId))
.groupBy(member.role),
]);
const memberCountByRole = new Map(
memberCounts.map((r) => [r.role, r.count]),
);
const roleMap = new Map<
string,
{
role: string;
permissions: Record<string, string[]>;
createdAt: Date;
ids: string[];
memberCount: number;
}
>();
for (const entry of roles) {
const existing = roleMap.get(entry.role);
const parsed = JSON.parse(entry.permission) as Record<string, string[]>;
if (existing) {
for (const [resource, actions] of Object.entries(parsed)) {
existing.permissions[resource] = [
...new Set([...(existing.permissions[resource] ?? []), ...actions]),
];
}
existing.ids.push(entry.id);
} else {
roleMap.set(entry.role, {
role: entry.role,
permissions: parsed,
createdAt: entry.createdAt,
ids: [entry.id],
memberCount: memberCountByRole.get(entry.role) ?? 0,
});
}
}
return Array.from(roleMap.values());
}),
create: enterpriseProcedure
.input(
z.object({
roleName: z
.string()
.min(1)
.max(50)
.refine(
(name) => !["owner", "admin", "member"].includes(name),
"Cannot use reserved role names (owner, admin, member)",
),
permissions: permissionsSchema,
}),
)
.mutation(async ({ input, ctx }) => {
const existingRoles = await db.query.organizationRole.findMany({
where: eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
});
const uniqueRoleNames = new Set(existingRoles.map((r) => r.role));
if (uniqueRoleNames.size >= 10) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Maximum of 10 custom roles per organization reached",
});
}
if (uniqueRoleNames.has(input.roleName)) {
throw new TRPCError({
code: "CONFLICT",
message: `Role "${input.roleName}" already exists`,
});
}
validatePermissions(input.permissions);
const [created] = await db
.insert(organizationRole)
.values({
organizationId: ctx.session.activeOrganizationId,
role: input.roleName,
permission: JSON.stringify(input.permissions),
})
.returning();
await audit(ctx, {
action: "create",
resourceType: "customRole",
resourceName: input.roleName,
});
return created;
}),
update: enterpriseProcedure
.input(
z.object({
roleName: z.string().min(1),
newRoleName: z
.string()
.min(1)
.max(50)
.refine(
(name) => !["owner", "admin", "member"].includes(name),
"Cannot use reserved role names (owner, admin, member)",
)
.optional(),
permissions: permissionsSchema,
}),
)
.mutation(async ({ input, ctx }) => {
if (["owner", "admin", "member"].includes(input.roleName)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot modify built-in roles",
});
}
const effectiveRoleName = input.newRoleName ?? input.roleName;
if (input.newRoleName && input.newRoleName !== input.roleName) {
const existing = await db.query.organizationRole.findFirst({
where: and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.newRoleName),
),
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Role "${input.newRoleName}" already exists`,
});
}
await db
.update(member)
.set({ role: input.newRoleName })
.where(
and(
eq(member.organizationId, ctx.session.activeOrganizationId),
eq(member.role, input.roleName),
),
);
}
validatePermissions(input.permissions);
const [updated] = await db
.update(organizationRole)
.set({
role: effectiveRoleName,
permission: JSON.stringify(input.permissions),
})
.where(
and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.roleName),
),
)
.returning();
await audit(ctx, {
action: "update",
resourceType: "customRole",
resourceName: effectiveRoleName,
});
return updated;
}),
remove: enterpriseProcedure
.input(
z.object({
roleName: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
if (["owner", "admin", "member"].includes(input.roleName)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot delete built-in roles",
});
}
const assignedMembers = await db.query.member.findMany({
where: and(
eq(member.organizationId, ctx.session.activeOrganizationId),
eq(member.role, input.roleName),
),
});
if (assignedMembers.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot delete role "${input.roleName}": ${assignedMembers.length} member(s) are currently assigned to it. Reassign them first.`,
});
}
const deleted = await db
.delete(organizationRole)
.where(
and(
eq(
organizationRole.organizationId,
ctx.session.activeOrganizationId,
),
eq(organizationRole.role, input.roleName),
),
)
.returning();
if (deleted.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Role "${input.roleName}" not found`,
});
}
await audit(ctx, {
action: "delete",
resourceType: "customRole",
resourceName: input.roleName,
});
return { deleted: deleted.length };
}),
membersByRole: protectedProcedure
.input(z.object({ roleName: z.string().min(1) }))
.query(async ({ input, ctx }) => {
const members = await db
.select({
id: member.id,
userId: member.userId,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
})
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(
and(
eq(member.organizationId, ctx.session.activeOrganizationId),
eq(member.role, input.roleName),
),
);
return members;
}),
getStatements: protectedProcedure.query(() => {
return statements;
}),
});
const INTERNAL_RESOURCES = ["organization", "invitation", "team", "ac"];
function validatePermissions(permissions: Record<string, string[]>) {
for (const [resource, actions] of Object.entries(permissions)) {
if (INTERNAL_RESOURCES.includes(resource)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Resource "${resource}" is managed internally and cannot be assigned to custom roles`,
});
}
if (!(resource in statements)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unknown resource: ${resource}`,
});
}
const validActions = statements[resource as keyof typeof statements];
for (const action of actions) {
if (!validActions.includes(action as never)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid action "${action}" for resource "${resource}". Valid actions: ${validActions.join(", ")}`,
});
}
}
}
}

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