Merge branch 'canary' into feature/add-custom-webhook-notification-provider

This commit is contained in:
Mauricio Siu
2025-12-07 13:23:59 -06:00
75 changed files with 31116 additions and 2033 deletions

View File

@@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can create an organization",
@@ -96,7 +96,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can update it",
@@ -119,7 +119,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
@@ -194,6 +194,65 @@ export const organizationRouter = createTRPCRouter({
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
}),
updateMemberRole: adminProcedure
.input(
z.object({
memberId: z.string(),
role: z.enum(["admin", "member"]),
}),
)
.mutation(async ({ ctx, input }) => {
// Fetch the target member
const target = await db.query.member.findFirst({
where: eq(member.id, input.memberId),
with: { user: true },
});
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
if (target.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to update this member's role",
});
}
// Prevent users from changing their own role
if (target.userId === ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot change your own role",
});
}
// Owner role is intransferible - cannot change to or from owner
if (target.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "The owner role is intransferible",
});
}
// Only owners can change admin roles
// Admins can only change member roles
if (ctx.user.role === "admin" && target.role === "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only the organization owner can change admin roles. Admins can only modify member roles.",
});
}
// Update the target member's role
await db
.update(member)
.set({ role: input.role })
.where(eq(member.id, input.memberId));
return true;
}),
setDefault: protectedProcedure
.input(
z.object({

View File

@@ -1,11 +1,13 @@
import {
canAccessToTraefikFiles,
checkGPUStatus,
cleanStoppedContainers,
cleanUpDockerBuilder,
cleanUpSystemPrune,
cleanUpUnusedImages,
cleanUpUnusedVolumes,
checkPortInUse,
cleanupAll,
cleanupBuilders,
cleanupContainers,
cleanupImages,
cleanupSystem,
cleanupVolumes,
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
@@ -130,6 +132,17 @@ export const settingsRouter = createTRPCRouter({
let newPorts = ports;
// If receive true, add 8080 to ports
if (input.enableDashboard) {
// Check if port 8080 is already in use before enabling dashboard
const portCheck = await checkPortInUse(8080, input.serverId);
if (portCheck.isInUse) {
const conflictingContainer = portCheck.conflictingContainer
? ` by container "${portCheck.conflictingContainer}"`
: "";
throw new TRPCError({
code: "CONFLICT",
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
});
}
newPorts.push({
targetPort: 8080,
publishedPort: 8080,
@@ -149,41 +162,38 @@ export const settingsRouter = createTRPCRouter({
cleanUnusedImages: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanupImages(input?.serverId);
return true;
}),
cleanUnusedVolumes: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedVolumes(input?.serverId);
await cleanupVolumes(input?.serverId);
return true;
}),
cleanStoppedContainers: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanStoppedContainers(input?.serverId);
await cleanupContainers(input?.serverId);
return true;
}),
cleanDockerBuilder: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpDockerBuilder(input?.serverId);
await cleanupBuilders(input?.serverId);
}),
cleanDockerPrune: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpSystemPrune(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanupSystem(input?.serverId);
await cleanupBuilders(input?.serverId);
return true;
}),
cleanAll: adminProcedure
.input(apiServerSchema)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanStoppedContainers(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanUpSystemPrune(input?.serverId);
await cleanupAll(input?.serverId);
return true;
}),
@@ -201,7 +211,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateUser(ctx.user.ownerId, {
sshPrivateKey: input.sshPrivateKey,
});
@@ -213,7 +223,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
const user = await updateUser(ctx.user.id, {
const user = await updateUser(ctx.user.ownerId, {
host: input.host,
...(input.letsEncryptEmail && {
letsEncryptEmail: input.letsEncryptEmail,
@@ -240,7 +250,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await updateUser(ctx.user.id, {
await updateUser(ctx.user.ownerId, {
sshPrivateKey: null,
});
return true;
@@ -281,9 +291,9 @@ export const settingsRouter = createTRPCRouter({
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages(server.serverId);
await cleanUpDockerBuilder(server.serverId);
await cleanUpSystemPrune(server.serverId);
await cleanupAll(server.serverId);
await sendDockerCleanupNotifications(server.organizationId);
});
}
@@ -300,7 +310,7 @@ export const settingsRouter = createTRPCRouter({
}
}
} else if (!IS_CLOUD) {
const userUpdated = await updateUser(ctx.user.id, {
const userUpdated = await updateUser(ctx.user.ownerId, {
enableDockerCleanup: input.enableDockerCleanup,
});
@@ -309,9 +319,9 @@ export const settingsRouter = createTRPCRouter({
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
);
await cleanUpUnusedImages();
await cleanUpDockerBuilder();
await cleanUpSystemPrune();
await cleanupAll();
await sendDockerCleanupNotifications(
ctx.session.activeOrganizationId,
);
@@ -810,6 +820,19 @@ export const settingsRouter = createTRPCRouter({
"dokploy-traefik",
input?.serverId,
);
for (const port of input.additionalPorts) {
const portCheck = await checkPortInUse(
port.publishedPort,
input.serverId,
);
if (portCheck.isInUse) {
throw new TRPCError({
code: "CONFLICT",
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
});
}
}
const preparedEnv = prepareEnvironmentVariables(env);
await writeTraefikSetup({

View File

@@ -56,15 +56,16 @@ export const stripeRouter = createTRPCRouter({
});
const items = getStripeItems(input.serverQuantity, input.isAnnual);
const user = await findUserById(ctx.user.id);
// Always operate on the organization owner's Stripe customer
const owner = await findUserById(ctx.user.ownerId);
let stripeCustomerId = user.stripeCustomerId;
let stripeCustomerId = owner.stripeCustomerId;
if (stripeCustomerId) {
const customer = await stripe.customers.retrieve(stripeCustomerId);
if (customer.deleted) {
await updateUser(user.id, {
await updateUser(owner.id, {
stripeCustomerId: null,
});
stripeCustomerId = null;
@@ -78,7 +79,7 @@ export const stripeRouter = createTRPCRouter({
customer: stripeCustomerId,
}),
metadata: {
adminId: user.id,
adminId: owner.id,
},
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
@@ -88,15 +89,16 @@ export const stripeRouter = createTRPCRouter({
return { sessionId: session.id };
}),
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
const user = await findUserById(ctx.user.id);
// Use the organization's owner account for billing portal
const owner = await findUserById(ctx.user.ownerId);
if (!user.stripeCustomerId) {
if (!owner.stripeCustomerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Stripe Customer ID not found",
});
}
const stripeCustomerId = user.stripeCustomerId;
const stripeCustomerId = owner.stripeCustomerId;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",

View File

@@ -1,6 +1,5 @@
import {
createApiKey,
findAdmin,
findNotificationById,
findOrganizationById,
findUserById,
@@ -87,7 +86,11 @@ export const userRouter = createTRPCRouter({
// Allow access if:
// 1. User is requesting their own information
// 2. User has owner role (admin permissions) AND user is in the same organization
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
if (
memberResult.userId !== ctx.user.id &&
ctx.user.role !== "owner" &&
ctx.user.role !== "admin"
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this user",
@@ -223,10 +226,61 @@ export const userRouter = createTRPCRouter({
userId: z.string(),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
// Ensure the acting user has admin privileges in the active organization
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners or admins can delete users",
});
}
// Fetch target member within the active organization
const targetMember = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
});
if (!targetMember) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Target user is not a member of this organization",
});
}
// Never allow deleting the organization owner via this endpoint
if (targetMember.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot delete the organization owner",
});
}
// Admin self-protection: an admin cannot delete themselves
if (targetMember.role === "admin" && input.userId === ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Admins cannot delete themselves. Ask the owner or another admin.",
});
}
// Only owners can delete admins
// Admins can only delete members
if (ctx.user.role === "admin" && targetMember.role === "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only the organization owner can delete admins. Admins can only delete members.",
});
}
return await removeUserById(input.userId);
}),
assignPermissions: adminProcedure