Compare commits

..

4 Commits

Author SHA1 Message Date
Mauricio Siu
6a7da40ffe Merge branch 'canary' into feat/add-custom-dokploy-loader 2026-03-01 15:53:33 -06:00
Mauricio Siu
7a1703a191 fix(show-registry): correct loading state condition for WhaleLoader display
- Updated the ShowRegistry component to display the WhaleLoader when loading is in progress, improving user experience during data fetching.
2026-02-08 02:27:01 -06:00
Mauricio Siu
f6af5daf5e refactor(animation): update whale-loader animations and styles
- Removed the deprecated "whale-draw" animation from the Tailwind CSS configuration.
- Added a new "whale-draw" keyframe animation in globals.css to enhance the whale-loader's visual effect.
- Updated the WhaleLoader component to utilize the new animation and adjusted stroke properties for improved rendering.
2026-02-08 02:26:49 -06:00
Mauricio Siu
452b9a3c78 feat(loader): add WhaleLoader component and animation
- Introduced a new WhaleLoader component that displays a loading animation using the Dokploy whale logo.
- Updated the Tailwind CSS configuration to include a new animation for the whale loader.
- Replaced the existing loading indicator in the ShowRegistry component with the new WhaleLoader for improved visual consistency.
2026-02-08 02:19:05 -06:00
195 changed files with 6126 additions and 28849 deletions

View File

@@ -1,11 +1,2 @@
LEMON_SQUEEZY_API_KEY="" LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID="" LEMON_SQUEEZY_STORE_ID=""
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
# INNGEST_BASE_URL="http://localhost:8288"
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
# INNGEST_SIGNING_KEY="your-signing-key"
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
# INNGEST_JOBS_MAX_EVENTS=100

View File

@@ -6,377 +6,3 @@ 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

@@ -10,7 +10,6 @@ import {
type DeployJob, type DeployJob,
deployJobSchema, deployJobSchema,
} from "./schema.js"; } from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js"; import { deploy } from "./utils.js";
const app = new Hono(); const app = new Hono();
@@ -119,6 +118,7 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
200, 200,
); );
} catch (error) { } catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error); logger.error("Failed to send deployment event", error);
return c.json( return c.json(
{ {
@@ -176,29 +176,6 @@ app.get("/health", async (c) => {
return c.json({ status: "ok" }); return c.json({ status: "ok" });
}); });
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
app.get("/jobs", async (c) => {
const serverId = c.req.query("serverId");
if (!serverId) {
return c.json({ message: "serverId is required" }, 400);
}
try {
const rows = await fetchDeploymentJobs(serverId);
return c.json(rows);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("INNGEST_BASE_URL")) {
return c.json(
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
503,
);
}
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
return c.json([], 200);
}
});
// Serve Inngest functions endpoint // Serve Inngest functions endpoint
app.on( app.on(
["GET", "POST", "PUT"], ["GET", "POST", "PUT"],

View File

@@ -1,239 +0,0 @@
import { logger } from "./logger.js";
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
const DEFAULT_MAX_EVENTS = 500;
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
type InngestEventRow = {
internal_id?: string;
accountID?: string;
environmentID?: string;
source?: string;
sourceID?: string | null;
/** RFC3339 timestamp API uses receivedAt, dev server may use received_at */
receivedAt?: string;
received_at?: string;
id: string;
name: string;
data: Record<string, unknown>;
user?: unknown;
ts: number;
v?: string | null;
metadata?: {
fetchedAt: string;
cachedUntil: string | null;
};
};
/** Run shape from GET /v1/events/{eventId}/runs the actual job execution */
type InngestRun = {
run_id: string;
event_id: string;
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
run_started_at?: string;
ended_at?: string | null;
output?: unknown;
// dev server / API may use different casing
run_started_at_ms?: number;
};
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
return ev.receivedAt ?? ev.received_at;
}
/** Map Inngest run status to BullMQ-style state for the UI */
function runStatusToState(
status: string,
): "pending" | "active" | "completed" | "failed" | "cancelled" {
const s = status.toLowerCase();
if (s === "running") return "active";
if (s === "completed") return "completed";
if (s === "failed") return "failed";
if (s === "cancelled") return "cancelled";
if (s === "queued") return "pending";
return "pending";
}
export const fetchInngestEvents = async () => {
const maxEvents = MAX_EVENTS;
const all: InngestEventRow[] = [];
let cursor: string | undefined;
do {
const params = new URLSearchParams({ limit: "100" });
if (cursor) {
params.set("cursor", cursor);
}
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
logger.warn("Inngest API error", {
status: res.status,
body: await res.text(),
});
break;
}
const body = (await res.json()) as {
data?: InngestEventRow[];
cursor?: string;
nextCursor?: string;
};
const data = Array.isArray(body.data) ? body.data : [];
all.push(...data);
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
const nextCursor =
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
cursor = hasMore ? nextCursor : undefined;
} while (cursor);
return all.slice(0, maxEvents);
};
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) runs are the actual jobs */
export const fetchInngestRunsForEvent = async (
eventId: string,
): Promise<InngestRun[]> => {
const res = await fetch(
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
{
headers: {
Authorization: `Bearer ${signingKey}`,
"Content-Type": "application/json",
},
},
);
if (!res.ok) {
logger.warn("Inngest runs API error", {
eventId,
status: res.status,
body: await res.text(),
});
return [];
}
const body = (await res.json()) as { data?: InngestRun[] };
return Array.isArray(body.data) ? body.data : [];
};
/** One row for the queue UI (BullMQ-compatible shape) */
export type DeploymentJobRow = {
id: string;
name: string;
data: Record<string, unknown>;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: string;
};
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
function buildDeploymentRowsFromRuns(
events: InngestEventRow[],
runsByEventId: Map<string, InngestRun[]>,
serverId: string,
): DeploymentJobRow[] {
const requested = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
const rows: DeploymentJobRow[] = [];
for (const ev of requested) {
const data = (ev.data ?? {}) as Record<string, unknown>;
const runs = runsByEventId.get(ev.id) ?? [];
if (runs.length === 0) {
// Queued: event received but no run yet
rows.push({
id: ev.id,
name: ev.name,
data,
timestamp: ev.ts,
processedOn: ev.ts,
finishedOn: undefined,
failedReason: undefined,
state: "pending",
});
continue;
}
for (const run of runs) {
const state = runStatusToState(run.status);
const runStartedMs =
run.run_started_at_ms ??
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
const endedMs = run.ended_at
? new Date(run.ended_at).getTime()
: undefined;
const failedReason =
state === "failed" &&
run.output &&
typeof run.output === "object" &&
"error" in run.output
? String((run.output as { error?: unknown }).error)
: undefined;
rows.push({
id: run.run_id,
name: ev.name,
data,
timestamp: runStartedMs,
processedOn: runStartedMs,
finishedOn:
state === "completed" || state === "failed" || state === "cancelled"
? endedMs
: undefined,
failedReason,
state,
});
}
}
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
}
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
export const fetchDeploymentJobs = async (
serverId: string,
): Promise<DeploymentJobRow[]> => {
if (!signingKey) {
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
return [];
}
if (!baseUrl) {
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
}
const events = await fetchInngestEvents();
const requestedForServer = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
// Limit to avoid too many run fetches
const toFetch = requestedForServer.slice(0, 50);
const runsByEventId = new Map<string, InngestRun[]>();
await Promise.all(
toFetch.map(async (ev) => {
const runs = await fetchInngestRunsForEvent(ev.id);
runsByEventId.set(ev.id, runs);
}),
);
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
};

View File

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

View File

@@ -1,144 +0,0 @@
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

@@ -1,78 +0,0 @@
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

@@ -1,161 +0,0 @@
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

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

View File

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

View File

@@ -15,17 +15,13 @@ 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 && canRead }, { enabled: !!applicationId },
); );
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,8 +60,6 @@ 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(
@@ -127,11 +125,9 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
} }
}} }}
> >
{canWrite && ( <DialogTrigger asChild>
<DialogTrigger asChild> <Button isLoading={isPending}>Modify</Button>
<Button isLoading={isPending}>Modify</Button> </DialogTrigger>
</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,13 +21,6 @@ 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 }),
@@ -57,7 +50,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
</CardDescription> </CardDescription>
</div> </div>
{canCreate && data && data?.mounts.length > 0 && ( {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>
@@ -70,11 +63,9 @@ 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>
{canCreate && ( <AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}> Add Volume
Add Volume </AddVolumes>
</AddVolumes>
)}
</div> </div>
) : ( ) : (
<div className="flex flex-col pt-2 gap-4"> <div className="flex flex-col pt-2 gap-4">
@@ -139,42 +130,38 @@ export const ShowVolumes = ({ id, type }: Props) => {
</div> </div>
</div> </div>
<div className="flex flex-row gap-1"> <div className="flex flex-row gap-1">
{canCreate && ( <UpdateVolume
<UpdateVolume mountId={mount.mountId}
mountId={mount.mountId} type={mount.type}
type={mount.type} refetch={refetch}
refetch={refetch} serviceType={type}
serviceType={type} />
/> <DialogAction
)} title="Delete Volume"
{canDelete && ( description="Are you sure you want to delete this volume?"
<DialogAction type="destructive"
title="Delete Volume" onClick={async () => {
description="Are you sure you want to delete this volume?" await deleteVolume({
type="destructive" mountId: mount.mountId,
onClick={async () => { })
await deleteVolume({ .then(() => {
mountId: mount.mountId, refetch();
toast.success("Volume deleted successfully");
}) })
.then(() => { .catch(() => {
refetch(); toast.error("Error deleting volume");
toast.success("Volume deleted successfully"); });
}) }}
.catch(() => { >
toast.error("Error deleting volume"); <Button
}); variant="ghost"
}} size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
> >
<Button <Trash2 className="size-4 text-primary group-hover:text-red-500" />
variant="ghost" </Button>
size="icon" </DialogAction>
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

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

View File

@@ -50,9 +50,6 @@ 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(
@@ -152,7 +149,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">
{canCreateDomain && data && data?.length > 0 && ( {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
@@ -176,15 +173,13 @@ 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>
{canCreateDomain && ( <div className="flex flex-row gap-4 flex-wrap">
<div className="flex flex-row gap-4 flex-wrap"> <AddDomain id={id} type={type}>
<AddDomain id={id} type={type}> <Button>
<Button> <GlobeIcon className="size-4" /> Add Domain
<GlobeIcon className="size-4" /> Add Domain </Button>
</Button> </AddDomain>
</AddDomain> </div>
</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] ">
@@ -219,51 +214,47 @@ export const ShowDomains = ({ id, type }: Props) => {
} }
/> />
)} )}
{canCreateDomain && ( <AddDomain
<AddDomain id={id}
id={id} type={type}
type={type} domainId={item.domainId}
domainId={item.domainId} >
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
> >
<Button <PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
variant="ghost" </Button>
size="icon" </AddDomain>
className="group hover:bg-blue-500/10" <DialogAction
> title="Delete Domain"
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" /> description="Are you sure you want to delete this domain?"
</Button> type="destructive"
</AddDomain> onClick={async () => {
)} await deleteDomain({
{canDeleteDomain && ( domainId: item.domainId,
<DialogAction })
title="Delete Domain" .then((_data) => {
description="Are you sure you want to delete this domain?" refetch();
type="destructive" toast.success(
onClick={async () => { "Domain deleted successfully",
await deleteDomain({ );
domainId: item.domainId,
}) })
.then((_data) => { .catch(() => {
refetch(); toast.error("Error deleting domain");
toast.success( });
"Domain deleted successfully", }}
); >
}) <Button
.catch(() => { variant="ghost"
toast.error("Error deleting domain"); size="icon"
}); className="group hover:bg-red-500/10"
}} isLoading={isRemoving}
> >
<Button <Trash2 className="size-4 text-primary group-hover:text-red-500" />
variant="ghost" </Button>
size="icon" </DialogAction>
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,8 +36,6 @@ 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 }),
@@ -187,27 +185,25 @@ PORT=3000
)} )}
/> />
{canWrite && ( <div className="flex flex-row justify-end gap-2">
<div className="flex flex-row justify-end gap-2"> {hasChanges && (
{hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<Button <Button
isLoading={isPending} type="button"
className="w-fit" variant="outline"
type="submit" onClick={handleCancel}
disabled={!hasChanges}
> >
Save Cancel
</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,8 +31,6 @@ 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();
@@ -203,30 +201,27 @@ 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>
)} )}
/> />
)} )}
{canWrite && ( <div className="flex flex-row justify-end gap-2">
<div className="flex flex-row justify-end gap-2"> {hasChanges && (
{hasChanges && ( <Button type="button" variant="outline" onClick={handleCancel}>
<Button type="button" variant="outline" onClick={handleCancel}> Cancel
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, HelpCircle, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, 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,8 +416,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</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 { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import { 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,8 +228,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
<p> <p>

View File

@@ -30,9 +30,6 @@ 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,
@@ -60,135 +57,128 @@ 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}>
{canDeploy && ( <DialogAction
<DialogAction title="Deploy Application"
title="Deploy Application" description="Are you sure you want to deploy this application?"
description="Are you sure you want to deploy this application?" type="default"
type="default" onClick={async () => {
onClick={async () => { await deploy({
await deploy({ applicationId: applicationId,
applicationId: applicationId, })
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
);
}) })
.then(() => { .catch(() => {
toast.success("Application deployed successfully"); toast.error("Error deploying application");
refetch(); });
router.push( }}
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`, >
); <Button
}) variant="default"
.catch(() => { isLoading={data?.applicationStatus === "running"}
toast.error("Error deploying application"); className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
});
}}
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>
</div> Downloads the source code and performs a complete build
</TooltipTrigger> </p>
<TooltipPrimitive.Portal> </TooltipContent>
<TooltipContent sideOffset={5} className="z-[60]"> </TooltipPrimitive.Portal>
<p> </Tooltip>
Downloads the source code and performs a complete </Button>
build </DialogAction>
</p> <DialogAction
</TooltipContent> title="Reload Application"
</TooltipPrimitive.Portal> description="Are you sure you want to reload this application?"
</Tooltip> type="default"
</Button> onClick={async () => {
</DialogAction> await reload({
)} applicationId: applicationId,
{canDeploy && ( appName: data?.appName || "",
<DialogAction })
title="Reload Application" .then(() => {
description="Are you sure you want to reload this application?" toast.success("Application reloaded successfully");
type="default" refetch();
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
}) })
.then(() => { .catch(() => {
toast.success("Application reloaded successfully"); toast.error("Error reloading application");
refetch(); });
}) }}
.catch(() => { >
toast.error("Error reloading application"); <Button
}); variant="secondary"
}} isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="secondary" <TooltipTrigger asChild>
isLoading={isReloading} <div className="flex items-center">
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <RefreshCcw className="size-4 mr-1" />
> Reload
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<RefreshCcw className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Reload <p>Reload the application without rebuilding it</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Reload the application without rebuilding it</p> </DialogAction>
</TooltipContent> <DialogAction
</TooltipPrimitive.Portal> title="Rebuild Application"
</Tooltip> description="Are you sure you want to rebuild this application?"
</Button> type="default"
</DialogAction> onClick={async () => {
)} await redeploy({
{canDeploy && ( applicationId: applicationId,
<DialogAction })
title="Rebuild Application" .then(() => {
description="Are you sure you want to rebuild this application?" toast.success("Application rebuilt successfully");
type="default" refetch();
onClick={async () => {
await redeploy({
applicationId: applicationId,
}) })
.then(() => { .catch(() => {
toast.success("Application rebuilt successfully"); toast.error("Error rebuilding application");
refetch(); });
}) }}
.catch(() => { >
toast.error("Error rebuilding application"); <Button
}); variant="secondary"
}} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="secondary" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2" <Hammer className="size-4 mr-1" />
> Rebuild
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Hammer className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Rebuild <p>
</div> Only rebuilds the application without downloading new
</TooltipTrigger> code
<TooltipPrimitive.Portal> </p>
<TooltipContent sideOffset={5} className="z-[60]"> </TooltipContent>
<p> </TooltipPrimitive.Portal>
Only rebuilds the application without downloading new </Tooltip>
code </Button>
</p> </DialogAction>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{canDeploy && data?.applicationStatus === "idle" ? ( {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?"
@@ -229,7 +219,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?"
@@ -266,7 +256,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : null} )}
</TooltipProvider> </TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
@@ -280,53 +270,49 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
Open Terminal Open Terminal
</Button> </Button>
</DockerTerminalModal> </DockerTerminalModal>
{canUpdateService && ( <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border"> <span className="text-sm font-medium">Autodeploy</span>
<span className="text-sm font-medium">Autodeploy</span> <Switch
<Switch aria-label="Toggle autodeploy"
aria-label="Toggle autodeploy" checked={data?.autoDeploy || false}
checked={data?.autoDeploy || false} onCheckedChange={async (enabled) => {
onCheckedChange={async (enabled) => { await update({
await update({ applicationId,
applicationId, autoDeploy: enabled,
autoDeploy: enabled, })
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
}) })
.then(async () => { .catch(() => {
toast.success("Auto Deploy Updated"); toast.error("Error updating Auto Deploy");
await refetch(); });
}) }}
.catch(() => { className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
toast.error("Error updating Auto Deploy"); />
}); </div>
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
)}
{canUpdateService && ( <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border"> <span className="text-sm font-medium">Clean Cache</span>
<span className="text-sm font-medium">Clean Cache</span> <Switch
<Switch aria-label="Toggle clean cache"
aria-label="Toggle clean cache" checked={data?.cleanCache || false}
checked={data?.cleanCache || false} onCheckedChange={async (enabled) => {
onCheckedChange={async (enabled) => { await update({
await update({ applicationId,
applicationId, cleanCache: enabled,
cleanCache: enabled, })
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
}) })
.then(async () => { .catch(() => {
toast.success("Clean Cache Updated"); toast.error("Error updating Clean Cache");
await refetch(); });
}) }}
.catch(() => { className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
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,8 +46,6 @@ 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 = {
@@ -125,8 +123,6 @@ 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,9 +19,6 @@ 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,
@@ -38,169 +35,162 @@ 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}>
{canDeploy && ( <DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction <DialogAction
title="Deploy Compose" title="Start Compose"
description="Are you sure you want to deploy this compose?" description="Are you sure you want to start this compose?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await deploy({ await start({
composeId: composeId, composeId: composeId,
}) })
.then(() => { .then(() => {
toast.success("Compose deployed successfully"); toast.success("Compose started successfully");
refetch(); refetch();
router.push(
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
);
}) })
.catch(() => { .catch(() => {
toast.error("Error deploying compose"); toast.error("Error starting compose");
}); });
}} }}
> >
<Button <Button
variant="default" variant="secondary"
isLoading={data?.composeStatus === "running"} isLoading={isStarting}
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">
<Rocket className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Deploy Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>
Downloads the source code and performs a complete build Start the compose (requires a previous successful build)
</p> </p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} ) : (
{canDeploy && (
<DialogAction <DialogAction
title="Reload Compose" title="Stop Compose"
description="Are you sure you want to reload this compose?" description="Are you sure you want to stop this compose?"
type="default"
onClick={async () => { onClick={async () => {
await redeploy({ await stop({
composeId: composeId, composeId: composeId,
}) })
.then(() => { .then(() => {
toast.success("Compose reloaded successfully"); toast.success("Compose stopped successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading compose"); toast.error("Error stopping compose");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="destructive"
isLoading={data?.composeStatus === "running"} isLoading={isStopping}
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">
<RefreshCcw className="size-4 mr-1" /> <Ban className="size-4 mr-1" />
Reload Stop
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p> <p>Stop the currently running compose</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 || ""}
@@ -215,29 +205,27 @@ export const ComposeActions = ({ composeId }: Props) => {
Open Terminal Open Terminal
</Button> </Button>
</DockerTerminalModal> </DockerTerminalModal>
{canUpdateService && ( <div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border"> <span className="text-sm font-medium">Autodeploy</span>
<span className="text-sm font-medium">Autodeploy</span> <Switch
<Switch aria-label="Toggle autodeploy"
aria-label="Toggle autodeploy" checked={data?.autoDeploy || false}
checked={data?.autoDeploy || false} onCheckedChange={async (enabled) => {
onCheckedChange={async (enabled) => { await update({
await update({ composeId,
composeId, autoDeploy: enabled,
autoDeploy: enabled, })
.then(async () => {
toast.success("Auto Deploy Updated");
await refetch();
}) })
.then(async () => { .catch(() => {
toast.success("Auto Deploy Updated"); toast.error("Error updating Auto Deploy");
await refetch(); });
}) }}
.catch(() => { className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
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,8 +26,6 @@ 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(
{ {
@@ -166,16 +164,14 @@ 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" />
{canUpdate && ( <Button
<Button type="submit"
type="submit" form="hook-form-save-compose-file"
form="hook-form-save-compose-file" isLoading={isPending}
isLoading={isPending} className="lg:w-fit w-full"
className="lg:w-fit w-full" >
> Save
Save </Button>
</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 { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import { 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,8 +230,10 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel> <FormLabel>Watch Paths</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" /> <div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
<p> <p>

View File

@@ -1,613 +0,0 @@
"use client";
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Boxes,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
Rocket,
Server,
} from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type DeploymentRow =
inferRouterOutputs<AppRouter>["deployment"]["allCentralized"][number];
const statusVariants: Record<
string,
| "default"
| "secondary"
| "destructive"
| "outline"
| "yellow"
| "green"
| "red"
> = {
running: "yellow",
done: "green",
error: "red",
cancelled: "outline",
};
function getServiceInfo(d: DeploymentRow) {
const app = d.application;
const comp = d.compose;
if (app?.environment?.project && app.environment) {
return {
type: "Application" as const,
name: app.name,
projectId: app.environment.project.projectId,
environmentId: app.environment.environmentId,
projectName: app.environment.project.name,
environmentName: app.environment.name,
serviceId: app.applicationId,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
type: "Compose" as const,
name: comp.name,
projectId: comp.environment.project.projectId,
environmentId: comp.environment.environmentId,
projectName: comp.environment.project.name,
environmentName: comp.environment.name,
serviceId: comp.composeId,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
export function ShowDeploymentsTable() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 50,
});
const { data: deploymentsList, isLoading } =
api.deployment.allCentralized.useQuery(undefined, {
refetchInterval: 5000,
});
const filteredData = useMemo(() => {
if (!deploymentsList) return [];
let list = deploymentsList;
if (statusFilter !== "all") {
list = list.filter((d) => d.status === statusFilter);
}
if (typeFilter === "application") {
list = list.filter((d) => d.applicationId != null);
} else if (typeFilter === "compose") {
list = list.filter((d) => d.composeId != null);
}
if (globalFilter.trim()) {
const q = globalFilter.toLowerCase();
list = list.filter((d) => {
const info = getServiceInfo(d);
const serverName =
d.server?.name ??
d.application?.server?.name ??
d.compose?.server?.name ??
"";
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
if (!info) return false;
return (
info.name.toLowerCase().includes(q) ||
info.projectName.toLowerCase().includes(q) ||
info.environmentName.toLowerCase().includes(q) ||
(d.title?.toLowerCase().includes(q) ?? false) ||
serverName.toLowerCase().includes(q) ||
buildServerName.toLowerCase().includes(q)
);
});
}
return list;
}, [deploymentsList, statusFilter, typeFilter, globalFilter]);
const columns = useMemo(
() => [
{
id: "serviceName",
accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Service
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return <span className="text-muted-foreground"></span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>
<Badge variant="outline" className="w-fit text-[10px]">
{info.type}
</Badge>
</div>
</div>
);
},
},
{
id: "projectName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.projectName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Project
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.projectName ?? "—"}
</span>
);
},
},
{
id: "environmentName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.environmentName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Environment
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.environmentName ?? "—"}
</span>
);
},
},
{
id: "serverName",
accessorFn: (row: DeploymentRow) =>
row.server?.name ??
row.application?.server?.name ??
row.compose?.server?.name ??
"",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Server
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const d = row.original;
const serverName =
d.server?.name ??
d.application?.server?.name ??
d.compose?.server?.name ??
null;
const serverType =
d.server?.serverType ??
d.application?.server?.serverType ??
d.compose?.server?.serverType ??
null;
const buildServerName =
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
const buildServerType =
d.buildServer?.serverType ??
d.application?.buildServer?.serverType ??
null;
const showBuild =
buildServerName != null && buildServerName !== serverName;
if (!serverName && !showBuild) {
return <span className="text-muted-foreground"></span>;
}
return (
<div className="flex flex-col gap-0.5 text-sm">
{serverName && (
<div className="flex items-center gap-1.5 flex-wrap">
<Server className="size-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{serverName}</span>
{serverType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{serverType}
</Badge>
)}
</div>
)}
{showBuild && buildServerName && (
<div className="flex items-center gap-1.5 text-muted-foreground flex-wrap">
<span className="text-[10px]">Build:</span>
<span className="truncate text-xs">{buildServerName}</span>
{buildServerType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{buildServerType}
</Badge>
)}
</div>
)}
</div>
);
},
},
{
accessorKey: "title",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Title
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-sm truncate max-w-[200px] block">
{row.original.title || "—"}
</span>
),
},
{
accessorKey: "status",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Status
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const status = row.original.status ?? "running";
return (
<Badge variant={statusVariants[status] ?? "secondary"}>
{status}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-muted-foreground text-sm whitespace-nowrap">
{row.original.createdAt
? new Date(row.original.createdAt).toLocaleString()
: "—"}
</span>
),
},
{
header: "",
id: "actions",
enableSorting: false,
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return null;
return (
<Button variant="ghost" size="sm" asChild>
<Link href={info.href} className="gap-1">
<ExternalLink className="size-4" />
Open
</Link>
</Button>
);
},
},
],
[],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
columnFilters,
globalFilter,
pagination,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-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>
{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=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,217 +0,0 @@
"use client";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type QueueRow =
inferRouterOutputs<AppRouter>["deployment"]["queueList"][number];
const stateVariants: Record<
string,
| "default"
| "secondary"
| "destructive"
| "outline"
| "yellow"
| "green"
| "red"
> = {
pending: "secondary",
waiting: "secondary",
active: "yellow",
delayed: "outline",
completed: "green",
failed: "destructive",
cancelled: "outline",
paused: "outline",
};
function formatTs(ts?: number): string {
if (ts == null) return "—";
const d = new Date(ts);
return d.toLocaleString();
}
function getJobLabel(row: QueueRow): string {
const d = row.data as {
applicationType?: string;
applicationId?: string;
composeId?: string;
previewDeploymentId?: string;
titleLog?: string;
type?: string;
};
if (!d) return String(row.id);
const type = d.applicationType ?? "job";
const title = d.titleLog ?? "";
if (title) return title;
if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}`;
if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}`;
if (d.previewDeploymentId)
return `Preview ${d.previewDeploymentId.slice(0, 8)}`;
return `${type} ${String(row.id)}`;
}
export function ShowQueueTable(props: { embedded?: boolean }) {
const { embedded: _embedded = false } = props;
const { data: queueList, isLoading } = api.deployment.queueList.useQuery(
undefined,
{ refetchInterval: 3000 },
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const utils = api.useUtils();
const {
mutateAsync: cancelApplicationDeployment,
isPending: isCancellingApp,
} = api.application.cancelDeployment.useMutation({
onSuccess: () => void utils.deployment.queueList.invalidate(),
});
const {
mutateAsync: cancelComposeDeployment,
isPending: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation({
onSuccess: () => void utils.deployment.queueList.invalidate(),
});
const isCancelling = isCancellingApp || isCancellingCompose;
return (
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[30vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading queue...</span>
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Job ID</TableHead>
<TableHead>Label</TableHead>
<TableHead>Type</TableHead>
<TableHead>State</TableHead>
<TableHead>Added</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Finished</TableHead>
<TableHead>Error</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queueList?.length ? (
queueList.map((row) => {
const d = row.data as Record<string, unknown>;
const appType = d?.applicationType as string | undefined;
const pathInfo = row.servicePath;
const hasLink = pathInfo?.href != null;
return (
<TableRow key={String(row.id)}>
<TableCell className="font-mono text-xs">
{String(row.id)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{getJobLabel(row)}
</TableCell>
<TableCell>{appType ?? row.name ?? "—"}</TableCell>
<TableCell>
<Badge variant={stateVariants[row.state] ?? "outline"}>
{row.state}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.timestamp)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.processedOn)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.finishedOn)}
</TableCell>
<TableCell className="max-w-[180px] truncate text-xs text-destructive">
{row.failedReason ?? "—"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{hasLink ? (
<Button variant="ghost" size="sm" asChild>
<Link href={pathInfo!.href!}>
<ArrowRight className="size-4 mr-1" />
Service
</Link>
</Button>
) : (
<span className="text-muted-foreground text-xs">
</span>
)}
{isCloud &&
row.state === "active" &&
(d?.applicationId != null ||
d?.composeId != null) && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isCancelling}
onClick={() => {
const appId =
typeof d.applicationId === "string"
? d.applicationId
: undefined;
const compId =
typeof d.composeId === "string"
? d.composeId
: undefined;
if (appId) {
void cancelApplicationDeployment({
applicationId: appId,
});
} else if (compId) {
void cancelComposeDeployment({
composeId: compId,
});
}
}}
>
<XCircle className="size-4 mr-1" />
Cancel
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={9} className="text-center py-12">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground min-h-[30vh]">
<ListTodo className="size-8" />
<p className="font-medium">Queue is empty</p>
<p className="text-sm">
Deployment jobs will appear here when they are queued.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

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

View File

@@ -21,8 +21,6 @@ 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,
@@ -74,75 +72,154 @@ 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">
{canDeploy && ( <TooltipProvider delayDuration={0}>
<TooltipProvider delayDuration={0}> <DialogAction
<DialogAction title="Deploy Mariadb"
title="Deploy Mariadb" description="Are you sure you want to deploy this mariadb?"
description="Are you sure you want to deploy this mariadb?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the MariaDB database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the MariaDB database</p> </DialogAction>
</TooltipContent> </TooltipProvider>
</TooltipPrimitive.Portal> <TooltipProvider delayDuration={0}>
</Tooltip> <DialogAction
</Button> title="Reload Mariadb"
</DialogAction> description="Are you sure you want to reload this mariadb?"
</TooltipProvider> type="default"
)} onClick={async () => {
{canDeploy && ( 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="Reload Mariadb" title="Start Mariadb"
description="Are you sure you want to reload this mariadb?" description="Are you sure you want to start this mariadb?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
mariadbId: mariadbId, mariadbId: mariadbId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Mariadb reloaded successfully"); toast.success("Mariadb started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading Mariadb"); toast.error("Error starting Mariadb");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
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">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p> <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>
@@ -150,90 +227,6 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
</DialogAction> </DialogAction>
</TooltipProvider> </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,8 +21,6 @@ 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,
@@ -75,158 +73,153 @@ 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}>
{canDeploy && ( <DialogAction
<DialogAction title="Deploy Mongo"
title="Deploy Mongo" description="Are you sure you want to deploy this mongo?"
description="Are you sure you want to deploy this mongo?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the MongoDB database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the MongoDB database</p> </DialogAction>
</TooltipContent> <DialogAction
</TooltipPrimitive.Portal> title="Reload Mongo"
</Tooltip> description="Are you sure you want to reload this mongo?"
</Button> type="default"
</DialogAction> onClick={async () => {
)} await reload({
{canDeploy && ( 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="Reload Mongo" title="Start Mongo"
description="Are you sure you want to reload this mongo?" description="Are you sure you want to start this mongo?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
mongoId: mongoId, mongoId: mongoId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Mongo reloaded successfully"); toast.success("Mongo started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading Mongo"); toast.error("Error starting Mongo");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
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">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p> <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 &&
(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,8 +21,6 @@ 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,
@@ -73,158 +71,153 @@ 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}>
{canDeploy && ( <DialogAction
<DialogAction title="Deploy MySQL"
title="Deploy MySQL" description="Are you sure you want to deploy this mysql?"
description="Are you sure you want to deploy this mysql?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the MySQL database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the MySQL database</p> </DialogAction>
</TooltipContent> <DialogAction
</TooltipPrimitive.Portal> title="Reload MySQL"
</Tooltip> description="Are you sure you want to reload this mysql?"
</Button> type="default"
</DialogAction> onClick={async () => {
)} await reload({
{canDeploy && ( 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="Reload MySQL" title="Start MySQL"
description="Are you sure you want to reload this mysql?" description="Are you sure you want to start this mysql?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
mysqlId: mysqlId, mysqlId: mysqlId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("MySQL reloaded successfully"); toast.success("MySQL started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading MySQL"); toast.error("Error starting MySQL");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
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">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p> <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 &&
(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

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

View File

@@ -21,8 +21,6 @@ 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,
@@ -75,162 +73,153 @@ 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}>
{canDeploy && ( <DialogAction
<DialogAction title="Deploy PostgreSQL"
title="Deploy PostgreSQL" description="Are you sure you want to deploy this postgres?"
description="Are you sure you want to deploy this postgres?" type="default"
type="default" onClick={async () => {
onClick={async () => { setIsDeploying(true);
setIsDeploying(true); await new Promise((resolve) => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000)); refetch();
refetch(); }}
}} >
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Button <Tooltip>
variant="default" <TooltipTrigger asChild>
isLoading={data?.applicationStatus === "running"} <div className="flex items-center">
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Rocket className="size-4 mr-1" />
> Deploy
<Tooltip> </div>
<TooltipTrigger asChild> </TooltipTrigger>
<div className="flex items-center"> <TooltipPrimitive.Portal>
<Rocket className="size-4 mr-1" /> <TooltipContent sideOffset={5} className="z-[60]">
Deploy <p>Downloads and sets up the PostgreSQL database</p>
</div> </TooltipContent>
</TooltipTrigger> </TooltipPrimitive.Portal>
<TooltipPrimitive.Portal> </Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> </Button>
<p>Downloads and sets up the PostgreSQL database</p> </DialogAction>
</TooltipContent> <DialogAction
</TooltipPrimitive.Portal> title="Reload PostgreSQL"
</Tooltip> description="Are you sure you want to reload this postgres?"
</Button> type="default"
</DialogAction> onClick={async () => {
)} await reload({
{canDeploy && ( 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="Reload PostgreSQL" title="Start PostgreSQL"
description="Are you sure you want to reload this postgres?" description="Are you sure you want to start this postgres?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await reload({ await start({
postgresId: postgresId, postgresId: postgresId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("PostgreSQL reloaded successfully"); toast.success("PostgreSQL started successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error reloading PostgreSQL"); toast.error("Error starting PostgreSQL");
}); });
}} }}
> >
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isStarting}
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">
<RefreshCcw className="size-4 mr-1" /> <CheckCircle2 className="size-4 mr-1" />
Reload Start
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
<p> <p>
Restart the PostgreSQL service without rebuilding Start the PostgreSQL database (requires a previous
successful setup)
</p> </p>
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button> </Button>
</DialogAction> </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>
)} )}
{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,13 +57,19 @@ export const AdvancedEnvironmentSelector = ({
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
// Get current user's permissions // Get current user's permissions
const { data: permissions } = api.user.getPermissions.useQuery(); const { data: currentUser } = api.user.get.useQuery();
// Check if user can create environments // Check if user can create environments
const canCreateEnvironments = !!permissions?.environment.create; const canCreateEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canCreateEnvironments === true;
// Check if user can delete environments // Check if user can delete environments
const canDeleteEnvironments = !!permissions?.environment.delete; const canDeleteEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canDeleteEnvironments === true;
const haveServices = const haveServices =
selectedEnvironment && selectedEnvironment &&

View File

@@ -39,9 +39,6 @@ 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 } =
@@ -100,10 +97,6 @@ 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>
@@ -148,7 +141,6 @@ 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
@@ -165,13 +157,11 @@ API_KEY=your-api-key-here
</FormItem> </FormItem>
)} )}
/> />
{canWrite && ( <DialogFooter>
<DialogFooter> <Button isLoading={isPending} type="submit">
<Button isLoading={isPending} type="submit"> Update
Update </Button>
</Button> </DialogFooter>
</DialogFooter>
)}
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -39,9 +39,6 @@ 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 } =
@@ -99,10 +96,6 @@ 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>
@@ -146,7 +139,6 @@ 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
@@ -162,13 +154,11 @@ PORT=3000
</FormItem> </FormItem>
)} )}
/> />
{canWrite && ( <DialogFooter>
<DialogFooter> <Button isLoading={isPending} type="submit">
<Button isLoading={isPending} type="submit"> Update
Update </Button>
</Button> </DialogFooter>
</DialogFooter>
)}
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -61,7 +61,6 @@ 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(
@@ -169,6 +168,11 @@ 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 ">
@@ -182,7 +186,9 @@ export const ShowProjects = () => {
Create and manage your projects Create and manage your projects
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
{permissions?.project.create && ( {(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateProjects) && (
<div className=""> <div className="">
<HandleProject /> <HandleProject />
</div> </div>
@@ -355,7 +361,8 @@ export const ShowProjects = () => {
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{permissions?.project.delete && ( {(auth?.role === "owner" ||
auth?.canDeleteProjects) && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger className="w-full"> <AlertDialogTrigger className="w-full">
<DropdownMenuItem <DropdownMenuItem

View File

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

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Loader2, Package, Trash2 } from "lucide-react"; import { Package, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { WhaleLoader } from "@/components/shared/whale-loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -16,7 +17,6 @@ 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">
@@ -32,10 +32,9 @@ export const ShowRegistry = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 py-8 border-t"> <CardContent className="space-y-2 py-8 border-t">
{isPending ? ( {isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]"> <div className="flex flex-row items-center justify-center min-h-[25vh]">
<span>Loading...</span> <WhaleLoader />
<Loader2 className="animate-spin size-4" />
</div> </div>
) : ( ) : (
<> <>
@@ -45,7 +44,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>
{permissions?.registry.create && <HandleRegistry />} <HandleRegistry />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
@@ -74,49 +73,45 @@ export const ShowRegistry = () => {
registryId={registry.registryId} registryId={registry.registryId}
/> />
{permissions?.registry.delete && ( <DialogAction
<DialogAction title="Delete Registry"
title="Delete Registry" description="Are you sure you want to delete this registry configuration?"
description="Are you sure you want to delete this registry configuration?" type="destructive"
type="destructive" onClick={async () => {
onClick={async () => { await mutateAsync({
await mutateAsync({ registryId: registry.registryId,
registryId: registry.registryId, })
.then(() => {
toast.success(
"Registry configuration deleted successfully",
);
refetch();
}) })
.then(() => { .catch(() => {
toast.success( toast.error(
"Registry configuration deleted successfully", "Error deleting registry configuration",
); );
refetch(); });
}) }}
.catch(() => { >
toast.error( <Button
"Error deleting registry configuration", variant="ghost"
); size="icon"
}); className="group hover:bg-red-500/10 "
}} isLoading={isRemoving}
> >
<Button <Trash2 className="size-4 text-primary group-hover:text-red-500" />
variant="ghost" </Button>
size="icon" </DialogAction>
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>
{permissions?.registry.create && ( <div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4"> <HandleRegistry />
<HandleRegistry /> </div>
</div>
)}
</div> </div>
)} )}
</> </>

View File

@@ -16,7 +16,6 @@ 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">
@@ -46,7 +45,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>
{permissions?.destination.create && <HandleDestinations />} <HandleDestinations />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
@@ -72,49 +71,43 @@ export const ShowDestinations = () => {
<HandleDestinations <HandleDestinations
destinationId={destination.destinationId} destinationId={destination.destinationId}
/> />
{permissions?.destination.delete && ( <DialogAction
<DialogAction title="Delete Destination"
title="Delete Destination" description="Are you sure you want to delete this destination?"
description="Are you sure you want to delete this destination?" type="destructive"
type="destructive" onClick={async () => {
onClick={async () => { await mutateAsync({
await mutateAsync({ destinationId: destination.destinationId,
destinationId: destination.destinationId, })
.then(() => {
toast.success(
"Destination deleted successfully",
);
refetch();
}) })
.then(() => { .catch(() => {
toast.success( toast.error("Error deleting destination");
"Destination deleted successfully", });
); }}
refetch(); >
}) <Button
.catch(() => { variant="ghost"
toast.error( size="icon"
"Error deleting destination", className="group hover:bg-red-500/10 "
); isLoading={isRemoving}
});
}}
> >
<Button <Trash2 className="size-4 text-primary group-hover:text-red-500" />
variant="ghost" </Button>
size="icon" </DialogAction>
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>
{permissions?.destination.create && ( <div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4"> <HandleDestinations />
<HandleDestinations /> </div>
</div>
)}
</div> </div>
)} )}
</> </>

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ 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">
@@ -47,7 +46,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>
{permissions?.sshKeys.create && <HandleSSHKeys />} <HandleSSHKeys />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 min-h-[25vh]"> <div className="flex flex-col gap-4 min-h-[25vh]">
@@ -85,47 +84,43 @@ 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} />
{permissions?.sshKeys.delete && ( <DialogAction
<DialogAction title="Delete SSH Key"
title="Delete SSH Key" description="Are you sure you want to delete this SSH Key?"
description="Are you sure you want to delete this SSH Key?" type="destructive"
type="destructive" onClick={async () => {
onClick={async () => { await mutateAsync({
await mutateAsync({ sshKeyId: sshKey.sshKeyId,
sshKeyId: sshKey.sshKeyId, })
.then(() => {
toast.success(
"SSH Key deleted successfully",
);
refetch();
}) })
.then(() => { .catch(() => {
toast.success( toast.error("Error deleting SSH Key");
"SSH Key deleted successfully", });
); }}
refetch(); >
}) <Button
.catch(() => { variant="ghost"
toast.error("Error deleting SSH Key"); size="icon"
}); className="group hover:bg-red-500/10 "
}} isLoading={isRemoving}
> >
<Button <Trash2 className="size-4 text-primary group-hover:text-red-500" />
variant="ghost" </Button>
size="icon" </DialogAction>
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>
{permissions?.sshKeys.create && ( <div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4"> <HandleSSHKeys />
<HandleSSHKeys /> </div>
</div>
)}
</div> </div>
)} )}
</> </>

View File

@@ -32,6 +32,7 @@ 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({
@@ -39,7 +40,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.string().min(1, "Role is required"), role: z.enum(["member", "admin"]),
notificationId: z.string().optional(), notificationId: z.string().optional(),
}); });
@@ -48,14 +49,13 @@ 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 } = authClient.useActiveOrganization();
const form = useForm<AddInvitation>({ const form = useForm<AddInvitation>({
defaultValues: { defaultValues: {
@@ -70,15 +70,19 @@ 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) => {
try { setIsLoading(true);
const result = await inviteMember({ const result = await authClient.organization.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!.id, invitationId: result.data.id,
notificationId: data.notificationId || "", notificationId: data.notificationId || "",
}) })
.then(() => { .then(() => {
@@ -92,11 +96,10 @@ 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}>
@@ -156,11 +159,6 @@ 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>
@@ -214,7 +212,7 @@ export const AddInvitation = () => {
)} )}
<DialogFooter className="flex w-full flex-row"> <DialogFooter className="flex w-full flex-row">
<Button <Button
isLoading={isInviting} isLoading={isLoading}
form="hook-form-add-invitation" form="hook-form-add-invitation"
type="submit" type="submit"
> >

View File

@@ -173,11 +173,9 @@ type AddPermissions = z.infer<typeof addPermissions>;
interface Props { interface Props {
userId: string; userId: string;
role?: string;
} }
export const AddUserPermissions = ({ userId, role }: Props) => { export const AddUserPermissions = ({ userId }: 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,
@@ -286,237 +284,226 @@ export const AddUserPermissions = ({ userId, role }: 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"
> >
{isCustomRole && ( <FormField
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground"> control={form.control}
This user has a custom role assigned. Capabilities are defined name="canCreateProjects"
by the role. You can still manage which projects, environments, render={({ field }) => (
and services they can access below. <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</div> <div className="space-y-0.5">
)} <FormLabel>Create Projects</FormLabel>
{!isCustomRole && ( <FormDescription>
<> Allow the user to create projects
<FormField </FormDescription>
control={form.control} </div>
name="canCreateProjects" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Create Projects</FormLabel> />
<FormDescription> </FormControl>
Allow the user to create projects </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canDeleteProjects"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Delete Projects</FormLabel>
)} <FormDescription>
/> Allow the user to delete projects
<FormField </FormDescription>
control={form.control} </div>
name="canDeleteProjects" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Delete Projects</FormLabel> />
<FormDescription> </FormControl>
Allow the user to delete projects </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canCreateServices"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Create Services</FormLabel>
)} <FormDescription>
/> Allow the user to create services
<FormField </FormDescription>
control={form.control} </div>
name="canCreateServices" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Create Services</FormLabel> />
<FormDescription> </FormControl>
Allow the user to create services </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canDeleteServices"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Delete Services</FormLabel>
)} <FormDescription>
/> Allow the user to delete services
<FormField </FormDescription>
control={form.control} </div>
name="canDeleteServices" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Delete Services</FormLabel> />
<FormDescription> </FormControl>
Allow the user to delete services </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canCreateEnvironments"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Create Environments</FormLabel>
)} <FormDescription>
/> Allow the user to create environments
<FormField </FormDescription>
control={form.control} </div>
name="canCreateEnvironments" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Create Environments</FormLabel> />
<FormDescription> </FormControl>
Allow the user to create environments </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canDeleteEnvironments"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Delete Environments</FormLabel>
)} <FormDescription>
/> Allow the user to delete environments
<FormField </FormDescription>
control={form.control} </div>
name="canDeleteEnvironments" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Delete Environments</FormLabel> />
<FormDescription> </FormControl>
Allow the user to delete environments </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canAccessToTraefikFiles"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Access to Traefik Files</FormLabel>
)} <FormDescription>
/> Allow the user to access to the Traefik Tab Files
<FormField </FormDescription>
control={form.control} </div>
name="canAccessToTraefikFiles" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Access to Traefik Files</FormLabel> />
<FormDescription> </FormControl>
Allow the user to access to the Traefik Tab Files </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canAccessToDocker"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Access to Docker</FormLabel>
)} <FormDescription>
/> Allow the user to access to the Docker Tab
<FormField </FormDescription>
control={form.control} </div>
name="canAccessToDocker" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Access to Docker</FormLabel> />
<FormDescription> </FormControl>
Allow the user to access to the Docker Tab </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canAccessToAPI"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Access to API/CLI</FormLabel>
)} <FormDescription>
/> Allow the user to access to the API/CLI
<FormField </FormDescription>
control={form.control} </div>
name="canAccessToAPI" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Access to API/CLI</FormLabel> />
<FormDescription> </FormControl>
Allow the user to access to the API/CLI </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canAccessToSSHKeys"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Access to SSH Keys</FormLabel>
)} <FormDescription>
/> Allow to users to access to the SSH Keys section
<FormField </FormDescription>
control={form.control} </div>
name="canAccessToSSHKeys" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Access to SSH Keys</FormLabel> />
<FormDescription> </FormControl>
Allow to users to access to the SSH Keys section </FormItem>
</FormDescription> )}
</div> />
<FormControl> <FormField
<Switch control={form.control}
checked={field.value} name="canAccessToGitProviders"
onCheckedChange={field.onChange} render={({ field }) => (
/> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
</FormControl> <div className="space-y-0.5">
</FormItem> <FormLabel>Access to Git Providers</FormLabel>
)} <FormDescription>
/> Allow to users to access to the Git Providers section
<FormField </FormDescription>
control={form.control} </div>
name="canAccessToGitProviders" <FormControl>
render={({ field }) => ( <Switch
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> checked={field.value}
<div className="space-y-0.5"> onCheckedChange={field.onChange}
<FormLabel>Access to Git Providers</FormLabel> />
<FormDescription> </FormControl>
Allow to users to access to the Git Providers section </FormItem>
</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.string().min(1), role: z.enum(["admin", "member"]),
}); });
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>; type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
interface Props { interface Props {
memberId: string; memberId: string;
currentRole: string; currentRole: "admin" | "member";
userEmail: string; userEmail: string;
} }
@@ -49,10 +49,6 @@ 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();
@@ -129,14 +125,6 @@ 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>
@@ -144,13 +132,6 @@ 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,10 +35,9 @@ 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 } = authClient.useSession();
return ( return (
<div className="w-full"> <div className="w-full">
@@ -90,39 +89,40 @@ 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/custom role // Other users can edit permissions if target is not themselves and target is a member
const isStaticAdminOrOwner =
member.role === "owner" || member.role === "admin";
const canEditPermissions = const canEditPermissions =
!isStaticAdminOrOwner && member.role !== "owner" &&
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/custom roles (not other admins or owners) // - Admin: Can only change member 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 !== "admin")); member.role === "member"));
const canDeleteMember = // Delete/Unlink follow same hierarchy as role changes
permissions?.member.delete ?? false; // - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
// - Admin: Can only delete/unlink members (not other admins or owner)
const canDelete =
member.role !== "owner" &&
!isCloud &&
member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
// Self-hosted: "Delete User" removes the user entirely const canUnlink =
// 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 !== "admin") || member.role === "member"));
(canDeleteMember && !isStaticAdminOrOwner));
const canDelete = canRemove && !isCloud;
const canUnlink = canRemove && !!isCloud;
const hasAnyAction = const hasAnyAction =
canEditPermissions || canEditPermissions ||
@@ -134,11 +134,6 @@ 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
@@ -184,7 +179,9 @@ export const ShowUsers = () => {
{canChangeRole && ( {canChangeRole && (
<ChangeRole <ChangeRole
memberId={member.id} memberId={member.id}
currentRole={member.role} currentRole={
member.role as "admin" | "member"
}
userEmail={member.user.email} userEmail={member.user.email}
/> />
)} )}
@@ -192,7 +189,6 @@ export const ShowUsers = () => {
{canEditPermissions && ( {canEditPermissions && (
<AddUserPermissions <AddUserPermissions
userId={member.user.id} userId={member.user.id}
role={member.role}
/> />
)} )}

View File

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

View File

@@ -11,7 +11,6 @@ import {
ChevronRight, ChevronRight,
ChevronsUpDown, ChevronsUpDown,
CircleHelp, CircleHelp,
ClipboardList,
Clock, Clock,
CreditCard, CreditCard,
Database, Database,
@@ -25,9 +24,7 @@ import {
LogIn, LogIn,
type LucideIcon, type LucideIcon,
Package, Package,
Palette,
PieChart, PieChart,
Rocket,
Server, Server,
ShieldCheck, ShieldCheck,
Star, Star,
@@ -93,21 +90,13 @@ 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: EnabledOpts) => boolean; isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
}; };
// NavItem type // NavItem type
@@ -121,7 +110,10 @@ type NavItem =
title: string; title: string;
icon: LucideIcon; icon: LucideIcon;
items: SingleNavItem[]; items: SingleNavItem[];
isEnabled?: (opts: EnabledOpts) => boolean; isEnabled?: (opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
}; };
// ExternalLink type // ExternalLink type
@@ -130,7 +122,7 @@ type ExternalLink = {
name: string; name: string;
url: string; url: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
isEnabled?: (opts: EnabledOpts) => boolean; isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean;
}; };
// Menu type // Menu type
@@ -153,21 +145,13 @@ const MENU: Menu = {
url: "/dashboard/projects", url: "/dashboard/projects",
icon: Folder, icon: Folder,
}, },
{
isSingle: true,
title: "Deployments",
url: "/dashboard/deployments",
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 and if user has monitoring.read // Only enabled in non-cloud environments
isEnabled: ({ isCloud, permissions }) => isEnabled: ({ isCloud }) => !isCloud,
!isCloud && !!permissions?.monitoring.read,
}, },
{ {
isSingle: true, isSingle: true,
@@ -175,44 +159,64 @@ 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, permissions }) => isEnabled: ({ isCloud, auth }) =>
!isCloud && !!permissions?.organization.update, !isCloud && (auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
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 users with access to Traefik files in non-cloud environments // Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ auth, 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 users with access to Docker in non-cloud environments // Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ auth, 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 users with access to Docker in non-cloud environments // Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ auth, 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 users with access to Docker in non-cloud environments // Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ auth, 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
@@ -279,8 +283,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: ({ permissions, isCloud }) => isEnabled: ({ auth, isCloud }) =>
!!(permissions?.organization.update && !isCloud), !!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
@@ -293,59 +297,70 @@ const MENU: Menu = {
title: "Remote Servers", title: "Remote Servers",
url: "/dashboard/settings/servers", url: "/dashboard/settings/servers",
icon: Server, icon: Server,
isEnabled: ({ permissions }) => !!permissions?.server.read, // Only enabled for admins
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 users with member.read permission // Only enabled for admins
isEnabled: ({ permissions }) => !!permissions?.member.read, isEnabled: ({ auth }) =>
}, !!(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 users with access to SSH keys // Only enabled for admins and users with access to SSH keys
isEnabled: ({ permissions }) => !!permissions?.sshKeys.read, isEnabled: ({ auth }) =>
!!(
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: ({ permissions }) => !!permissions?.organization.update, isEnabled: ({ auth }) =>
!!(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 users with access to Git providers // Only enabled for admins and users with access to Git providers
isEnabled: ({ permissions }) => !!permissions?.gitProviders.read, isEnabled: ({ auth }) =>
!!(
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,
isEnabled: ({ permissions }) => !!permissions?.registry.read, // Only enabled for admins
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,
isEnabled: ({ permissions }) => !!permissions?.destination.read, // Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
@@ -353,7 +368,9 @@ const MENU: Menu = {
title: "Certificates", title: "Certificates",
url: "/dashboard/settings/certificates", url: "/dashboard/settings/certificates",
icon: ShieldCheck, icon: ShieldCheck,
isEnabled: ({ permissions }) => !!permissions?.certificate.read, // Only enabled for admins
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -361,23 +378,24 @@ 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: ({ permissions, isCloud }) => isEnabled: ({ auth, isCloud }) =>
!!(permissions?.organization.update && !isCloud), !!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
title: "Notifications", title: "Notifications",
url: "/dashboard/settings/notifications", url: "/dashboard/settings/notifications",
icon: Bell, icon: Bell,
// Only enabled for users with access to notifications // Only enabled for admins
isEnabled: ({ permissions }) => !!permissions?.notification.read, isEnabled: ({ auth }) =>
!!(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 owners in cloud environments // Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
}, },
{ {
@@ -385,7 +403,7 @@ const MENU: Menu = {
title: "License", title: "License",
url: "/dashboard/settings/license", url: "/dashboard/settings/license",
icon: Key, icon: Key,
// Only enabled for owners // Only enabled for admins in non-cloud environments
isEnabled: ({ auth }) => !!(auth?.role === "owner"), isEnabled: ({ auth }) => !!(auth?.role === "owner"),
}, },
{ {
@@ -394,15 +412,8 @@ 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: ({ permissions }) => !!permissions?.organization.update, isEnabled: ({ auth }) =>
}, !!(auth?.role === "owner" || auth?.role === "admin"),
{
isSingle: true,
title: "Whitelabeling",
url: "/dashboard/settings/whitelabeling",
icon: Palette,
// Only enabled for owners in non-cloud environments (enterprise)
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
}, },
], ],
@@ -426,45 +437,39 @@ const MENU: Menu = {
*/ */
function createMenuForAuthUser(opts: { function createMenuForAuthUser(opts: {
auth?: AuthQueryOutput; auth?: AuthQueryOutput;
permissions?: PermissionsOutput;
isCloud: boolean; isCloud: boolean;
whitelabeling?: {
docsUrl?: string | null;
supportUrl?: string | null;
} | null;
}): Menu { }): Menu {
const filterEnabled = < return {
T extends { // Filter the home items based on the user's role and permissions
isEnabled?: (o: EnabledOpts) => boolean; // Calls the `isEnabled` function if it exists to determine if the item should be displayed
}, home: MENU.home.filter((item) =>
>(
items: readonly T[],
): T[] =>
items.filter((item) =>
!item.isEnabled !item.isEnabled
? true ? true
: item.isEnabled({ : item.isEnabled({
auth: opts.auth, auth: opts.auth,
permissions: opts.permissions,
isCloud: opts.isCloud, isCloud: opts.isCloud,
}), }),
) as T[]; ),
// Filter the settings items based on the user's role and permissions
// Apply whitelabeling URL overrides to help items // Calls the `isEnabled` function if it exists to determine if the item should be displayed
const helpItems = filterEnabled(MENU.help).map((item) => { settings: MENU.settings.filter((item) =>
if (opts.whitelabeling?.docsUrl && item.name === "Documentation") { !item.isEnabled
return { ...item, url: opts.whitelabeling.docsUrl }; ? true
} : item.isEnabled({
if (opts.whitelabeling?.supportUrl && item.name === "Support") { auth: opts.auth,
return { ...item, url: opts.whitelabeling.supportUrl }; isCloud: opts.isCloud,
} }),
return item; ),
}); // Filter the help items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
return { help: MENU.help.filter((item) =>
home: filterEnabled(MENU.home), !item.isEnabled
settings: filterEnabled(MENU.settings), ? true
help: helpItems, : item.isEnabled({
auth: opts.auth,
isCloud: opts.isCloud,
}),
),
}; };
} }
@@ -534,7 +539,7 @@ function SidebarLogo() {
const { state } = useSidebar(); const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery(); const { data: user } = api.user.get.useQuery();
const { data: session } = api.user.session.useQuery(); const { data: session } = authClient.useSession();
const { const {
data: organizations, data: organizations,
refetch, refetch,
@@ -545,8 +550,8 @@ 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 } = authClient.useActiveOrganization();
const { data: activeOrganization } = api.organization.active.useQuery(); const _utils = api.useUtils();
const { data: invitations, refetch: refetchInvitations } = const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery(); api.user.getInvitations.useQuery();
@@ -571,7 +576,9 @@ function SidebarLogo() {
<SidebarMenu <SidebarMenu
className={cn( className={cn(
"flex gap-2", "flex gap-2",
isCollapsed ? "flex-col" : "flex-row justify-between items-center", state === "collapsed"
? "flex-col"
: "flex-row justify-between items-center",
)} )}
> >
{/* Organization Logo and Selector */} {/* Organization Logo and Selector */}
@@ -579,17 +586,17 @@ function SidebarLogo() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size={isCollapsed ? "sm" : "lg"} size={state === "collapsed" ? "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",
isCollapsed && state === "collapsed" &&
"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",
isCollapsed && "justify-center", state === "collapsed" && "justify-center",
)} )}
> >
<div <div
@@ -601,7 +608,7 @@ function SidebarLogo() {
<Logo <Logo
className={cn( className={cn(
"transition-all", "transition-all",
isCollapsed ? "size-4" : "size-5", state === "collapsed" ? "size-4" : "size-5",
)} )}
logoUrl={activeOrganization?.logo || undefined} logoUrl={activeOrganization?.logo || undefined}
/> />
@@ -609,7 +616,7 @@ function SidebarLogo() {
<div <div
className={cn( className={cn(
"flex flex-col items-start", "flex flex-col items-start",
isCollapsed && "hidden", state === "collapsed" && "hidden",
)} )}
> >
<p className="text-sm font-medium leading-none"> <p className="text-sm font-medium leading-none">
@@ -618,7 +625,7 @@ function SidebarLogo() {
</div> </div>
</div> </div>
<ChevronsUpDown <ChevronsUpDown
className={cn("ml-auto", isCollapsed && "hidden")} className={cn("ml-auto", state === "collapsed" && "hidden")}
/> />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -767,7 +774,7 @@ function SidebarLogo() {
</SidebarMenuItem> </SidebarMenuItem>
{/* Notification Bell */} {/* Notification Bell */}
<SidebarMenuItem className={cn(isCollapsed && "mt-2")}> <SidebarMenuItem className={cn(state === "collapsed" && "mt-2")}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -775,7 +782,7 @@ function SidebarLogo() {
size="icon" size="icon"
className={cn( className={cn(
"relative", "relative",
isCollapsed && "h-8 w-8 p-1.5 mx-auto", state === "collapsed" && "h-8 w-8 p-1.5 mx-auto",
)} )}
> >
<Bell className="size-4" /> <Bell className="size-4" />
@@ -871,12 +878,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, {
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const includesProjects = pathname?.includes("/dashboard/project"); const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -885,12 +887,7 @@ export default function Page({ children }: Props) {
home: filteredHome, home: filteredHome,
settings: filteredSettings, settings: filteredSettings,
help, help,
} = createMenuForAuthUser({ } = createMenuForAuthUser({ auth, isCloud: !!isCloud });
auth,
permissions,
isCloud: !!isCloud,
whitelabeling,
});
const activeItem = findActiveNavItem( const activeItem = findActiveNavItem(
[...filteredHome, ...filteredSettings], [...filteredHome, ...filteredSettings],
@@ -1130,7 +1127,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 && permissions?.organization.update && ( {!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
<SidebarMenuItem> <SidebarMenuItem>
<UpdateServerButton /> <UpdateServerButton />
</SidebarMenuItem> </SidebarMenuItem>
@@ -1138,15 +1135,15 @@ export default function Page({ children }: Props) {
<SidebarMenuItem> <SidebarMenuItem>
<UserNav /> <UserNav />
</SidebarMenuItem> </SidebarMenuItem>
{whitelabeling?.footerText && (
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
{whitelabeling.footerText}
</div>
)}
{dokployVersion && ( {dokployVersion && (
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden"> <>
Version {dokployVersion} <div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
</div> Version {dokployVersion}
</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,7 +21,6 @@ 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();
@@ -95,7 +94,9 @@ export const UserNav = () => {
> >
Monitoring Monitoring
</DropdownMenuItem> </DropdownMenuItem>
{permissions?.traefikFiles.read && ( {(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToTraefikFiles) && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {
@@ -105,7 +106,9 @@ export const UserNav = () => {
Traefik Traefik
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{permissions?.docker.read && ( {(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToDocker) && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {
@@ -119,7 +122,7 @@ export const UserNav = () => {
)} )}
</> </>
) : ( ) : (
permissions?.organization.update && ( (data?.role === "owner" || data?.role === "admin") && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {

View File

@@ -1,230 +0,0 @@
"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

@@ -1,400 +0,0 @@
"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

@@ -1,112 +0,0 @@
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

@@ -1,891 +0,0 @@
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

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

View File

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

View File

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

View File

@@ -17,8 +17,6 @@ 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;
@@ -34,11 +32,9 @@ 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 px-4"> <div className="flex items-center justify-between w-full">
<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" />
@@ -79,7 +75,6 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>
{!isCloud && <TimeBadge />}
</div> </div>
</header> </header>
); );

View File

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

View File

@@ -0,0 +1,57 @@
import { cn } from "@/lib/utils";
const WHALE_PATHS = [
"M390 56v12c.1 2.3.5 4 1 6a73 73 0 0 0 12 24c2 2.3 5.7 4 7 7 4 3.4 9.6 6.8 14 9 1.7.6 5.7 1.1 7 2 1.9 1.3 2.9 2.3 0 4v1c-.6 1.8-1.9 3.5-3 5q-3 4-7 7c-4.3 3.2-9.5 6.8-15 7h-1q-2 1.6-5 2h-4c-5.2.7-12.9 2.2-18 0h-6c-1.6 0-3-.8-4-1h-3a17 17 0 0 1-6-2h-1c-2.5-.1-4-1.2-6-2l-4-1c-8.4-2-20.3-6.6-27-12h-1c-4.6-1-9.5-4.3-13.7-6.3s-10.5-3-13.3-6.7h-1c-4-1-8.9-3.5-12-6h-1c-6.8-1.6-13.6-6-20-9-6.5-2.8-14.6-5.7-20-10h-1c-7-1.2-15.4-4-22-6h-97c-5.3 4.3-13.7 4.3-18.7 10.3S90.8 101 88 108c-.4 1.5-.8 2.3-1 4-.2 1.6-.8 4-1 5v51c.2 1.2.8 3.2 1 5 .2 2 .5 3.2 1 5a79 79 0 0 0 6 12c.8.7 1.4 2.2 2 3 1.8 2 4.9 3.4 6 6 9.5 8.3 23.5 10.3 33 18h1c5.1 1.2 12 4.8 16 8h1c4 1 8.9 3.5 12 6h1q4.6 1.2 8 4h1c2 .1 2.6 1.3 4 2 1.6.8 2.7.7 4 2h1q2.5.3 4 2h1c3 .7 6.7 2 9 4h1c4.7.8 13.4 3.1 17 6h1c2.5.1 4 1.3 6 2 1.8.4 3 .8 5 1q3 .4 5 1c1.6-.2 2 0 3 1h1q2.5-.5 4 1h1q2.5-.5 4 1h1c2.2-.2 4.5-.3 6 1h1q4-.4 7 1h45c1.2-.2 3.1-1 5-1h6c1.5-.6 2.9-1.3 5-1h1q1.5-1.4 4-1h1q1.5-1.4 4-1h1c2.4-1.3 5-1.6 8-2l5-1c2-.7 3.6-1.6 6-2 4-.7 7.2-1.7 11-3 2.3-1 4.2-2.5 7-3h1q1.5-1.7 4-2h1c1.9-1.5 3.9-2 6-3q2.9-1.6 6-3a95 95 0 0 0 11-5c4.4-2.8 8.9-6 14-8 0 0 .6.2 1 0 1.8-2.8 7-4.8 10-6 0 0 .6.2 1 0 1.5-2.4 5.3-4 8-5 0 0 .6.2 1 0 1.5-2.4 5.3-4 8-5 0 0 .6.2 1 0 1.3-2 3.8-3.1 6-4 0 0 .6.2 1 0 2-3 7.7-5.6 11-7l5-2c6.3-3.8 11.8-9.6 18-14v-1c0-1.9-.4-4.2 0-6-1-4.5-3.9-5.5-7-8h-1c-1.2 0-2.8-.2-4 0-8.9 1.7-16.5 11.3-25.2 14.8-8.8 3.4-16.9 10.7-25.8 14.2h-1c-10.9 10.6-29.2 16-42.7 23.3S343.7 234.6 328 235h-1q-1.5 1.4-4 1h-1q-1.5 1.4-4 1h-1c-1.5 1.3-3.9 1.2-6 1h-1c-1.7 1.3-4.6 1.2-7 1-1 .2-2.4 1-4 1h-5c-6.6 0-13.4.4-20 0-1.9-.1-2.7.3-4-1h-8c-2.8-.2-5.7-1.3-8-2h-2q-5.7.4-10-2h-1q-4.5 0-8-2h-1a10 10 0 0 1-6-2h-1c-5.9-.2-12-3.8-17-6l-4-1c-1.7-.5-2.8-.7-4-2h-1q-2.5-.2-4-2h-1q-3.4-.9-6-3h-1c-3.5-.8-7.3-2.9-10-5h-1c-1.7 0-2.2-.7-3-2h-1c-11.6-2.7-23.2-11.5-34.2-15.8-11-4.2-25.9-9.2-29.8-21.2h4c16.2 0 32.8-1 49 0 1.7.1 3 .8 4 1 2.1.4 3.4-.5 5 1h1c3.6.1 8.4 1.8 11 4h1a45 45 0 0 1 18 8h1q4.6 1.2 8 4h1c4.2 1 8.3 3.4 12 5q3.4 1.2 7 2c5.7 1.3 13 2.3 18 5h1c3.7-.2 7 1.1 10 2h9c1.6 0 3 .8 4 1h32c2.2-1.6 6-1 9-1h1a63 63 0 0 1 22-4 22 22 0 0 1 8-2c1.7-1.4 3.7-1.6 6-2a81 81 0 0 0 12-3c2.3-1 4.2-2.5 7-3h1q1.5-1.7 4-2h1c1.9-1.5 3.6-2.2 6-3l3-1c4.1-2.3 8.4-5.2 13-7 0 0 .6.2 1 0 1.5-2.4 6.3-5 9-6 0 0 .6.2 1 0 5.3-8.1 17.6-12.5 24.8-20.2C439.9 144 445 133 452 126v-1a12 12 0 0 1 2-5c2.1-2.2 8.9-1 12-1q2 .2 4 0c1-.2 2.3-1.2 4-1h1q2.1-1.5 5-2h1q2.1-1.9 5-3s.6.2 1 0c9-9.3 18-15.4 23-28 1.1-2.8 3.5-6.4 4-9 .2-1 .2-3 0-4-1.5-6-12.3-2.4-15.7 2.3S484.7 80 479 80h-7c-7.8 4.3-19.3 5.7-23 16a37 37 0 0 0-22-24c-1.5-.5-2.5-.7-4-1-2.1-.5-3.6-.2-5-2h-1a22 22 0 0 1-12-8c-2-2.9-3.4-6.5-6-9h-1c-3.9-.6-6.1 1-8 4m-181 45h1c2.2-.2 4.5-.3 6 1h1q2.5-.5 4 1h1a33 33 0 0 1 17 7h1c4.4 1 8.2 4.1 12 6 2.1 1 4.1 1.5 6 3h1c4 1 8.9 3.5 12 6h1c4 1 8.9 3.5 12 6h1c4 1 8.9 3.5 12 6h1a61 61 0 0 1 21 10h1c3.5.8 7.3 2.9 10 5h1c6.1 1.4 12.3 5 18 7 1.8.4 3 .8 5 1 1.8.2 3.7.8 5 1q2.5-.5 4 1h6c2.5 0 4 .3 6 1h3q-.7 2.1-3 2a46 46 0 0 1-16 7l-10 3c-2 .8-3.4 1.9-6 2h-1c-2.6 2.1-7.5 3-11 3h-1c-3.1 2.5-10.7 3.5-15 3h-1c-1.5 1.3-3.9 1.2-6 1-1 .2-2.4 1-4 1h-11c-3.8.4-8.3.4-12 0h-9c-2.3 0-4.3-.7-6-1h-3c-1.8 0-2.9-.7-4-1-3.5-.8-7-.7-10-2h-1c-4.1-.7-9.8-1.4-13-4h-1q-4-.6-7-3h-1q-2.5-.2-4-2h-1q-3.4-.9-6-3h-1c-7.2-1.7-13.3-5.9-20.2-8.8-7-2.8-16.2-4.3-22.8-7.2h-11c-14 0-28.9.3-42-1-2.3 0-4.8.3-7 0a6 6 0 0 1-5-5c-1.8-4.8-.4-10.4 0-15 0-4.3-.4-8.7 0-13 .2-3.2 2.2-7.3 4-10q2-3 5-5c2.1-2 5.4-2.3 8-3 15.6-3.9 36.3-1 53-1 5.2 0 12-.5 17 0s12.2-1.8 16 1Z",
"M162 132v1c1.8 2.9 4.5 5.3 8 6 .3-.2 3.7-.2 4 0 7-1.4 9.2-8.8 7-15v-1a14 14 0 0 0-7-4c-.3.2-3.7.2-4 0-6.5 1.3-8.6 6.8-8 13Z",
"M465 211h-1c-18.2 14.6-41.2 24.6-60 39-19 14.2-42.7 29.3-66 34l-4 1c-2.4 1-4 2-7 2h-1q-3.5 2-8 2h-1c-1.3 1.2-3 1.1-5 1h-2q-2.6 1.1-6 1h-2c-3 1.2-6.5 1-10 1-6.3.6-13.8.6-20 0-3.4 0-8.4.9-11-1h-1c-2.2.2-4.5.3-6-1h-1c-2 .2-3.7.2-5-1h-1c-7.6.5-16.5-3.4-23-6l-4-1a129 129 0 0 1-36.2-15.8c-10.4-6.6-23.2-12.8-32.5-20.5-9.2-7.7-23.8-12.8-30.3-22.7h-1c-2.3-1.4-4.5-2.7-6-5h-1c-4-2.5-8.5-5.2-12-8h-9a9 9 0 0 0-6 7c.3 3.3 0 6.7 0 10v9c.2 1.6 1 3.8 1 6v3c.2 1 1.2 2.2 1 4v1c1.2 1.2.8 2.2 1 4 .8 6.7 3 12.6 5 19 1.7 4.3 4.2 9.1 5 14v1q1.8 1.5 2 4v1a36 36 0 0 1 5 10c.7 2 1 3 2 5 8 12.7 15.7 25.5 25.8 37.3 10 11.7 20.8 20.6 32.4 30.4 11.7 9.9 28.3 14 39.8 23.3h1q2.5.3 4 2h1c2.8.4 4.8 2 7 3l7 2c5.7 1.3 13 2.3 18 5h1c2.1-.3 3.6.8 5 1h3c2.8.2 5.8 1 8 2h8c2.1 0 4.6.8 6 1h21c1.2-.2 3.2-1 5-1h9c3.3-1 7-2.4 11-2h1c2.7-2.2 7.4-2.4 11-3a55 55 0 0 0 8-2c6.5-2.6 13.9-6.3 21-8h1c8.5-6.8 20.6-9.7 29.2-16.8 8.7-7 18.3-12.8 26.8-20.2 4.4-3.8 9-9 13-13 14.8-14.8 20.7-34.6 33-50v-1q.9-3.4 3-6v-1q.3-2.5 2-4v-1c.5-3.3 2-8.6 4-11v-1q0-3.5 2-6v-1c1.1-6.7 2.4-15 5-21v-1c-.2-2-.2-3.7 1-5v-8c0-5.3-.5-10.8 0-16a14 14 0 0 0-4-6c-1-.5-1.1-.4-2-1h-6q-2.1 1.5-5 2m-6 38c-2.1 13.4-21.2 20.3-31 30-10 9.5-23.7 19-35 27-11.5 8-25.1 19.7-39 23h-1a22 22 0 0 1-10 4h-1a25 25 0 0 1-12 4h-1q-3.5 2-8 2h-1c-1.1 1.1-2.3 1-4 1h-2c-1.2.4-2.2 1-4 1h-2c-1.8.7-3.6 1.3-6 1h-1c-1.2 1.2-2.3 1-4 1h-5c-5.7.6-12.3.8-18 0h-4c-1.9 0-2.7-.6-4-1h-6c-1.9 0-2.7.3-4-1h-1q-2.5.5-4-1h-1c-8.1.5-16.8-3.6-24.2-5.8S210 329.8 204 325h-1c-12.8-5-27.1-15.6-37.7-24.3S138.8 284.2 131 273c-.3-.2-1 0-1 0-5.7-4.4-16.6-10-19-17-.9-2.6-1-5.4-2-8-.8-2.2-2.5-5-2-8a667 667 0 0 0 88 56h1q3.4.9 6 3h1c2.8.4 4.8 2 7 3q5 1.8 10 3l6 2q2.9.6 6 1 3 .4 5 1c1.6-.2 2 0 3 1h1c2-.2 3.7-.2 5 1h1c2.2-.3 3.4.4 5 1h8c1.6 0 3 .9 4 1h40c1.8-1.3 4.6-1.2 7-1h1c1.2-1.2 3.2-1.2 5-1h1c1.2-1.2 3.2-1.2 5-1h1c1.1-1.1 2.3-1 4-1h2c3.5-1.7 6.9-2.3 11-3l4-1c3.4-1.4 7.1-3 11-4 1.5-.4 2.5-.5 4-1 1.4-.7 2-1.9 4-2h1q2.6-2.1 6-3h1c2.5-2 6-3.8 9-5l3-1c1.4-.9 2-2.5 4-3h1q1.4-2.2 4-3h1c7.3-7.7 19-13.2 27.7-19.3 8.8-6.1 18.2-15 28.3-18.7.4-.2 1 0 1 0q3.8-3.9 9-6c1.3 2.5-.5 6.7-1 10m-20 55c-.2.4 0 1 0 1-3.4 9.6-12.7 19-19 27a88 88 0 0 1-12 12 214 214 0 0 1-26.7 20.3c-9.5 5.8-20 14.8-31.3 16.7h-1a22 22 0 0 1-10 4h-1c-3.2 2.6-8.9 3.3-13 4h-1q-1.5 1.4-4 1h-1q-1.5 1.4-4 1h-1c-4.9 2.3-10.5 1-16 2-1 .2-2.5 1-4 1-6.2.4-12.8.3-19 0-1.8 0-3.8-.8-5-1h-4c-1.6 0-3-.9-4-1h-4c-3.9-.3-8.8-1.3-12-3h-1c-3.3-.5-7.5-1-10-3h-1c-3.6-.1-8.4-1.8-11-4h-1c-3.9-.6-8-2.6-11-5h-1c-16.1-3.8-32.2-18.9-45-29a200 200 0 0 1-40-51c17.7 11.5 35 25.5 52 38h1c4 1.6 12.8 5.4 15 9h1c4.6 1 10.4 4.1 14 7h1q2.5.3 4 2h1c3.3.5 8.6 2 11 4h1q3.5 0 6 2h1q2.5-.5 4 1h1q2.5-.5 4 1h1c3.8-.2 7.9 1 11 2h9c1.6 0 3 .8 4 1h32c1.2-.2 3.2-1 5-1h8a139 139 0 0 1 20-4l5-1c2-.7 3.7-1.5 6-2l4-1c1.5-.6 3-1.7 5-2h1q3-2.4 7-3h1q2.6-2.1 6-3h1c11.7-9.4 27.6-14.6 39-25 11.6-10.3 25-18.5 37-28a15 15 0 0 1-5 10Z",
] as const;
interface WhaleLoaderProps {
className?: string;
}
/** Loader using the Dokploy whale logo: draws the whale gradually and bobs up/down like a tide. */
export const WhaleLoader = ({ className }: WhaleLoaderProps) => {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-3",
className,
)}
aria-label="Loading"
>
{/* animate-whale-tide */}
<div className="">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 559 446"
className="size-20 text-primary"
aria-hidden
>
<g
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{WHALE_PATHS.map((d, i) => (
<path
key={i}
pathLength={1}
strokeDasharray={1}
strokeDashoffset={1}
className="animate-whale-draw"
style={{
animationDelay: `${i * 0.25}s`,
}}
d={d}
/>
))}
</g>
</svg>
</div>
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
);
};

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
import { ssoClient } from "@better-auth/sso/client"; import { 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,113 +0,0 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { hasPermission } from "@dokploy/server/services/permission";
import { Rocket } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { useRouter } from "next/router";
import type { ReactElement } from "react";
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const TAB_VALUES = ["deployments", "queue"] as const;
type TabValue = (typeof TAB_VALUES)[number];
function isValidTab(t: string): t is TabValue {
return TAB_VALUES.includes(t as TabValue);
}
function DeploymentsPage() {
const router = useRouter();
const tab =
router.query.tab && isValidTab(router.query.tab as string)
? (router.query.tab as TabValue)
: "deployments";
const setTab = (value: string) => {
if (!isValidTab(value)) return;
router.replace(
{ pathname: "/dashboard/deployments", query: { tab: value } },
undefined,
{ shallow: true },
);
};
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<Rocket className="size-5" />
Deployments
</CardTitle>
<CardDescription>
All application and compose deployments in one place.
</CardDescription>
</div>
</div>
<Tabs value={tab} onValueChange={setTab} className="w-full">
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<TabsContent value="deployments" className="mt-0 pt-4">
<ShowDeploymentsTable />
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
</TabsContent>
</Tabs>
</CardHeader>
</div>
</Card>
</div>
);
}
export default DeploymentsPage;
DeploymentsPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const canView = await hasPermission(
{
user: { id: user.id },
session: { activeOrganizationId: session?.activeOrganizationId || "" },
},
{ deployment: ["read"] },
);
if (!canView) {
return {
redirect: {
permanent: false,
destination: "/dashboard/projects",
},
};
}
return {
props: {},
};
}

View File

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

View File

@@ -1,6 +1,5 @@
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";
@@ -100,7 +99,7 @@ export async function getServerSideProps(
}, },
}; };
} }
const { user, session } = await validateRequest(ctx.req); const { user } = await validateRequest(ctx.req);
if (!user) { if (!user) {
return { return {
redirect: { redirect: {
@@ -110,23 +109,6 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
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,15 +48,19 @@ 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();
const userPermissions = await helpers.user.getPermissions.fetch(); if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userPermissions?.gitProviders.read) { if (!userR?.canAccessToGitProviders) {
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: permissions } = api.user.getPermissions.useQuery(); const { data } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
return ( return (
@@ -19,7 +19,9 @@ 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 />}
{permissions?.api.read && <ShowApiKeys />} {(data?.canAccessToAPI ||
data?.role === "owner" ||
data?.role === "admin") && <ShowApiKeys />}
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -3,24 +3,16 @@ 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 />
{canCreateMembers && <ShowInvitations />} <ShowInvitations />
{isOwnerOrAdmin && <ManageCustomRoles />}
</div> </div>
); );
}; };
@@ -36,7 +28,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) { if (!user || user.role === "member") {
return { return {
redirect: { redirect: {
permanent: true, permanent: true,
@@ -56,30 +48,12 @@ export async function getServerSideProps(
}, },
transformer: superjson, transformer: superjson,
}); });
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
try { return {
await helpers.user.get.prefetch(); props: {
await helpers.settings.isCloud.prefetch(); trpcState: helpers.dehydrate(),
},
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

@@ -1,81 +0,0 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { WhitelabelingSettings } from "@/components/proprietary/whitelabeling/whitelabeling-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Enterprise Whitelabeling",
description:
"Whitelabeling allows you to fully customize logos, colors, CSS, error pages, and more. Add a valid license to configure it.",
ctaLabel: "Go to License",
}}
>
<WhitelabelingSettings />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role !== "owner") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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