Files
dokploy/apps/dokploy/server/api/routers/user.ts
Mauricio Siu c84b271511 feat(invitation): add email provider selection and notification handling for user invitations
- Introduced a new optional field for notificationId in the invitation form.
- Implemented fetching of email providers based on the active organization.
- Enhanced invitation sending logic to include email notifications when applicable.
- Updated UI to conditionally display email provider selection based on cloud status.
2025-06-21 13:08:49 -06:00

424 lines
9.8 KiB
TypeScript

import {
IS_CLOUD,
createApiKey,
findAdmin,
findNotificationById,
findOrganizationById,
findUserById,
getUserByToken,
removeUserById,
sendEmailNotification,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
account,
apiAssignPermissions,
apiFindOneToken,
apiUpdateUser,
apikey,
invitation,
member,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, asc, eq, gt } from "drizzle-orm";
import { z } from "zod";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "../trpc";
const apiCreateApiKey = z.object({
name: z.string().min(1),
prefix: z.string().optional(),
expiresIn: z.number().optional(),
metadata: z.object({
organizationId: z.string(),
}),
// Rate limiting
rateLimitEnabled: z.boolean().optional(),
rateLimitTimeWindow: z.number().optional(),
rateLimitMax: z.number().optional(),
// Request limiting
remaining: z.number().optional(),
refillAmount: z.number().optional(),
refillInterval: z.number().optional(),
});
export const userRouter = createTRPCRouter({
all: adminProcedure.query(async ({ ctx }) => {
return await db.query.member.findMany({
where: eq(member.organizationId, ctx.session.activeOrganizationId),
with: {
user: true,
},
orderBy: [asc(member.createdAt)],
});
}),
one: protectedProcedure
.input(
z.object({
userId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, input.userId),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
user: true,
},
});
return memberResult;
}),
get: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
user: {
with: {
apiKeys: true,
},
},
},
});
return memberResult;
}),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) {
return false;
}
if (
process.env.USER_ADMIN_ID === ctx.user.id ||
ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID
) {
return true;
}
return false;
}),
getBackups: adminProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
user: {
with: {
backups: {
with: {
destination: true,
deployments: true,
},
},
apiKeys: true,
},
},
},
});
return memberResult?.user;
}),
getServerMetrics: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
eq(member.userId, ctx.user.id),
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
),
with: {
user: true,
},
});
return memberResult?.user;
}),
update: protectedProcedure
.input(apiUpdateUser)
.mutation(async ({ input, ctx }) => {
if (input.password || input.currentPassword) {
const currentAuth = await db.query.account.findFirst({
where: eq(account.userId, ctx.user.id),
});
const correctPassword = bcrypt.compareSync(
input.currentPassword || "",
currentAuth?.password || "",
);
if (!correctPassword) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Current password is incorrect",
});
}
if (!input.password) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "New password is required",
});
}
await db
.update(account)
.set({
password: bcrypt.hashSync(input.password, 10),
})
.where(eq(account.userId, ctx.user.id));
}
return await updateUser(ctx.user.id, input);
}),
getUserByToken: publicProcedure
.input(apiFindOneToken)
.query(async ({ input }) => {
return await getUserByToken(input.token);
}),
getMetricsToken: protectedProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
return {
serverIp: user.serverIp,
enabledFeatures: user.enablePaidFeatures,
metricsConfig: user?.metricsConfig,
};
}),
remove: protectedProcedure
.input(
z.object({
userId: z.string(),
}),
)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return true;
}
return await removeUserById(input.userId);
}),
assignPermissions: adminProcedure
.input(apiAssignPermissions)
.mutation(async ({ input, ctx }) => {
try {
const organization = await findOrganizationById(
ctx.session?.activeOrganizationId || "",
);
if (organization?.ownerId !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to assign permissions",
});
}
const { id, ...rest } = input;
await db
.update(member)
.set({
...rest,
})
.where(
and(
eq(member.userId, input.id),
eq(
member.organizationId,
ctx.session?.activeOrganizationId || "",
),
),
);
} catch (error) {
throw error;
}
}),
getInvitations: protectedProcedure.query(async ({ ctx }) => {
return await db.query.invitation.findMany({
where: and(
eq(invitation.email, ctx.user.email),
gt(invitation.expiresAt, new Date()),
eq(invitation.status, "pending"),
),
with: {
organization: true,
},
});
}),
getContainerMetrics: protectedProcedure
.input(
z.object({
url: z.string(),
token: z.string(),
appName: z.string(),
dataPoints: z.string(),
}),
)
.query(async ({ input }) => {
try {
if (!input.appName) {
throw new Error(
[
"No Application Selected:",
"",
"Make Sure to select an application to monitor.",
].join("\n"),
);
}
const url = new URL(`${input.url}/metrics/containers`);
url.searchParams.append("limit", input.dataPoints);
url.searchParams.append("appName", input.appName);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${input.token}`,
},
});
if (!response.ok) {
throw new Error(
`Error ${response.status}: ${response.statusText}. Please verify that the application "${input.appName}" is running and this service is included in the monitoring configuration.`,
);
}
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
throw new Error(
[
`No monitoring data available for "${input.appName}". This could be because:`,
"",
"1. The container was recently started - wait a few minutes for data to be collected",
"2. The container is not running - verify its status",
"3. The service is not included in your monitoring configuration",
].join("\n"),
);
}
return data as {
containerId: string;
containerName: string;
containerImage: string;
containerLabels: string;
containerCommand: string;
containerCreated: string;
}[];
} catch (error) {
throw error;
}
}),
generateToken: protectedProcedure.mutation(async () => {
return "token";
}),
deleteApiKey: protectedProcedure
.input(
z.object({
apiKeyId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const apiKeyToDelete = await db.query.apikey.findFirst({
where: eq(apikey.id, input.apiKeyId),
});
if (!apiKeyToDelete) {
throw new TRPCError({
code: "NOT_FOUND",
message: "API key not found",
});
}
if (apiKeyToDelete.userId !== ctx.user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this API key",
});
}
await db.delete(apikey).where(eq(apikey.id, input.apiKeyId));
return true;
} catch (error) {
throw error;
}
}),
createApiKey: protectedProcedure
.input(apiCreateApiKey)
.mutation(async ({ input, ctx }) => {
const apiKey = await createApiKey(ctx.user.id, input);
return apiKey;
}),
checkUserOrganizations: protectedProcedure
.input(
z.object({
userId: z.string(),
}),
)
.query(async ({ input }) => {
const organizations = await db.query.member.findMany({
where: eq(member.userId, input.userId),
});
return organizations.length;
}),
sendInvitation: adminProcedure
.input(
z.object({
invitationId: z.string().min(1),
notificationId: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return;
}
const notification = await findNotificationById(input.notificationId);
const email = notification.email;
const currentInvitation = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!email) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Email notification not found",
});
}
const admin = await findAdmin();
const host =
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: admin.user.host;
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
try {
await sendEmailNotification(
{
...email,
toAddresses: [currentInvitation?.email || ""],
},
"Invitation to join organization",
`
<p>You are invited to join ${organization?.name || "organization"} on Dokploy. Click the link to accept the invitation: <a href="${inviteLink}">Accept Invitation</a></p>
`,
);
} catch (error) {
console.log(error);
throw error;
}
return inviteLink;
}),
});