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
199 changed files with 6173 additions and 28728 deletions

View File

@@ -1,11 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
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
LEMON_SQUEEZY_STORE_ID=""

View File

@@ -10,7 +10,6 @@ import {
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -119,6 +118,7 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
200,
);
} catch (error) {
console.log("error", error);
logger.error("Failed to send deployment event", error);
return c.json(
{
@@ -176,29 +176,6 @@ app.get("/health", async (c) => {
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
app.on(
["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,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema.js";
import type { DeployJob } from "./schema";
export const deploy = async (job: DeployJob) => {
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: "",
},
},
whitelabelingConfig: {
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -230,8 +230,10 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<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 { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
type User = typeof authClient.$Infer.Session.user;
export const ImpersonationBar = () => {
const { config: whitelabeling } = useWhitelabeling();
const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isImpersonating, setIsImpersonating] = useState(false);
@@ -182,10 +180,7 @@ export const ImpersonationBar = () => {
)}
>
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
<Logo
className="w-10 h-10"
logoUrl={whitelabeling?.logoUrl || undefined}
/>
<Logo className="w-10 h-10" />
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
<Popover open={open} onOpenChange={setOpen}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ const _AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const UserNav = () => {
const router = useRouter();
const { data } = api.user.get.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
// const { mutateAsync } = api.auth.logout.useMutation();
@@ -95,7 +94,9 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
{permissions?.traefikFiles.read && (
{(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToTraefikFiles) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -105,7 +106,9 @@ export const UserNav = () => {
Traefik
</DropdownMenuItem>
)}
{permissions?.docker.read && (
{(data?.role === "owner" ||
data?.role === "admin" ||
data?.canAccessToDocker) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -119,7 +122,7 @@ export const UserNav = () => {
)}
</>
) : (
permissions?.organization.update && (
(data?.role === "owner" || data?.role === "admin") && (
<DropdownMenuItem
className="cursor-pointer"
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,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";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { TimeBadge } from "@/components/ui/time-badge";
import { api } from "@/utils/api";
interface BreadcrumbEntry {
name: string;
@@ -34,11 +32,9 @@ interface Props {
}
export const BreadcrumbSidebar = ({ list }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center justify-between w-full px-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -79,7 +75,6 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
</BreadcrumbList>
</Breadcrumb>
</div>
{!isCloud && <TimeBadge />}
</div>
</header>
);

View File

@@ -4,14 +4,12 @@ import {
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { search, searchKeymap } from "@codemirror/search";
import { EditorView, keymap } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
@@ -132,7 +130,7 @@ function dockerComposeComplete(
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties" | "shell" | "css";
language?: "yaml" | "json" | "properties" | "shell";
lineWrapping?: boolean;
lineNumbers?: boolean;
}
@@ -157,17 +155,13 @@ export const CodeEditor = ({
}}
theme={resolvedTheme === "dark" ? githubDark : githubLight}
extensions={[
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
: language === "json"
? json()
: language === "css"
? css()
: language === "shell"
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
: language === "shell"
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [],
language === "yaml"
? 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}
>
<div className="flex h-full w-full flex-col overflow-hidden">
{children}
</div>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
@@ -414,7 +412,7 @@ const SidebarContent = React.forwardRef<
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-y-auto",
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...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,
"tag": "0147_right_lake",
"breakpoints": true
},
{
"idx": 148,
"version": "7",
"when": 1773129798212,
"tag": "0148_futuristic_bullseye",
"breakpoints": true
},
{
"idx": 149,
"version": "7",
"when": 1773637297592,
"tag": "0149_rare_radioactive_man",
"breakpoints": true
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { hasPermission } from "@dokploy/server/services/permission";
import { Loader2 } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -100,7 +99,7 @@ export async function getServerSideProps(
},
};
}
const { user, session } = await validateRequest(ctx.req);
const { user } = await validateRequest(ctx.req);
if (!user) {
return {
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 {
props: {},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,15 +49,19 @@ export async function getServerSideProps(
await helpers.project.all.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) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
if (!userR?.canAccessToSSHKeys) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
return {
props: {

View File

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

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 {
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) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
if (!userR?.canAccessToDocker) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
return {
props: {

View File

@@ -53,15 +53,19 @@ export async function getServerSideProps(
try {
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) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
if (!userR?.canAccessToTraefikFiles) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
return {
props: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,15 +98,19 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
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) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
if (!userR?.canAccessToAPI) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
return {

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