diff --git a/apps/api/docker-compose.yml b/apps/api/docker-compose.yml index e7220ca6f..5667d5fd0 100644 --- a/apps/api/docker-compose.yml +++ b/apps/api/docker-compose.yml @@ -13,6 +13,11 @@ services: volumes: - postgres_data:/var/lib/postgresql/data + mailhog: + image: jcalonso/mailhog + ports: + - 1025:1025 # smtp server + - 8025:8025 # web ui volumes: postgres_data: diff --git a/apps/api/package.json b/apps/api/package.json index 41eec030a..ade8215e0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "@types/bcrypt": "^5.0.0", "@types/jsonwebtoken": "^8.5.8", "@types/node": "^17.0.23", + "@types/nodemailer": "^6.4.14", "@types/passport-local": "^1.0.35", "prisma": "5.2.0", "ts-node": "^10.7.0", @@ -38,6 +39,7 @@ "imap": "^0.8.19", "jsonwebtoken": "9.0.2", "mailparser": "^3.6.5", + "nodemailer": "^6.9.7", "posthog-node": "^3.1.3", "prisma": "^5.6.0" }, diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index c4a0d5dfe..7f85004e1 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -1,7 +1,10 @@ +import axios from "axios"; import bcrypt from "bcrypt"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import jwt from "jsonwebtoken"; +import { track } from "../lib/hog"; import { checkToken } from "../lib/jwt"; +import { forgotPassword } from "../lib/nodemailer/auth/forgot-password"; import { prisma } from "../prisma"; export function authRoutes(fastify: FastifyInstance) { @@ -42,7 +45,7 @@ export function authRoutes(fastify: FastifyInstance) { }); } - await prisma.user.create({ + const user = await prisma.user.create({ data: { email, password: await bcrypt.hash(password, 10), @@ -51,13 +54,116 @@ export function authRoutes(fastify: FastifyInstance) { }, }); + const hog = track(); + + hog.capture({ + event: "user_registered", + distinctId: user.id, + }); + reply.send({ success: true, }); } ); - // User login route + // Forgot password & generate code + fastify.post( + "/api/v1/auth/password-reset", + async (request: FastifyRequest, reply: FastifyReply) => { + const { email, link } = request.body as { email: string; link: string }; + + let user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + reply.code(401).send({ + message: "Invalid email", + success: false, + }); + } + + function generateRandomCode() { + const min = 100000; // Minimum 6-digit number + const max = 999999; // Maximum 6-digit number + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + const code = generateRandomCode(); + + const uuid = await prisma.passwordResetToken.create({ + data: { + userId: user!.id, + code: String(code), + }, + }); + + forgotPassword(email, String(code), link, uuid.id); + + reply.send({ + success: true, + }); + } + ); + + // Check code & uuid us valid + fastify.post( + "/api/v1/auth/password-reset/code", + async (request: FastifyRequest, reply: FastifyReply) => { + const { code, uuid } = request.body as { code: string; uuid: string }; + + const reset = await prisma.passwordResetToken.findUnique({ + where: { code: code, id: uuid }, + }); + + if (!reset) { + reply.code(401).send({ + message: "Invalid Code", + success: false, + }); + } else { + reply.send({ + success: true, + }); + } + } + ); + + // Reset users password via code + fastify.post( + "/api/v1/auth/password-reset/password", + async (request: FastifyRequest, reply: FastifyReply) => { + const { password, code } = request.body as { + password: string; + code: string; + }; + + const user = await prisma.passwordResetToken.findUnique({ + where: { code: code }, + }); + + if (!user) { + reply.code(401).send({ + message: "Invalid Code", + success: false, + }); + } + + await prisma.user.update({ + where: { id: user!.userId }, + data: { + password: await bcrypt.hash(password, 10), + }, + }); + + reply.send({ + success: true, + }); + } + ); + + // User password login route fastify.post( "/api/v1/auth/login", { @@ -137,6 +243,133 @@ export function authRoutes(fastify: FastifyInstance) { } ); + // Checks if a user is SSO or password + fastify.post( + "/api/v1/auth/sso/check", + async (request: FastifyRequest, reply: FastifyReply) => { + let { email } = request.body as { + email: string; + }; + + let user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + reply.code(401).send({ + message: "Invalid email", + success: false, + }); + } + + const authtype = await prisma.config.findMany({ + where: { + sso_active: true, + }, + }); + + const provider = await prisma.provider.findMany(); + const oauth = provider[0]; + + console.log(authtype); + + if (authtype.length === 0) { + reply.code(200).send({ + success: true, + message: "SSO not enabled", + oauth: false, + }); + } + + const url = "https://github.com/login/oauth/authorize"; + + reply.send({ + oauth: true, + success: true, + ouath_url: `${url}?client_id=${oauth.clientId}&redirect_uri=${oauth.redirectUri}&state=${email}&login=${email}&scope=user`, + }); + } + ); + + // SSO api callback route + fastify.get( + "/api/v1/auth/sso/login/callback", + async (request: FastifyRequest, reply: FastifyReply) => { + const { code, state } = request.query as { code: string; state: string }; + + const provider = await prisma.provider.findFirst({}); + + const data = await axios.post( + `https://github.com/login/oauth/access_token`, + { + client_id: provider?.clientId, + client_secret: provider?.clientSecret, + code: code, + redirect_uri: provider?.redirectUri, + }, + { + headers: { + Accept: "application/json", + }, + } + ); + + const access_token = data.data; + + if (access_token) { + const gh = await axios.get(`https://api.github.com/user/emails`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `token ${access_token.access_token}`, + }, + }); + + const emails = gh.data; + + const filter = emails.filter((e: any) => e.primary === true); + + let user = await prisma.user.findUnique({ + where: { email: filter[0].email }, + }); + + if (!user) { + reply.send({ + success: false, + message: "Invalid email", + }); + } + + var b64string = process.env.SECRET; + var buf = new Buffer(b64string!, "base64"); // Ta-da + + let token = jwt.sign( + { + data: { id: user!.id }, + }, + buf, + { expiresIn: "8h" } + ); + + await prisma.session.create({ + data: { + userId: user!.id, + sessionToken: token, + expires: new Date(Date.now() + 8 * 60 * 60 * 1000), + }, + }); + + reply.send({ + token, + success: true, + }); + } else { + reply.status(403).send({ + success: false, + }); + } + } + ); + // Delete a user fastify.delete( "/api/v1/auth/user/:id", @@ -177,10 +410,12 @@ export function authRoutes(fastify: FastifyInstance) { if (!user) { reply.code(401).send({ - message: "Invalid email or password", + message: "Invalid user", }); } + const config = await prisma.config.findFirst(); + const data = { id: user!.id, email: user!.email, @@ -191,6 +426,7 @@ export function authRoutes(fastify: FastifyInstance) { ticket_status_changed: user!.notify_ticket_status_changed, ticket_comments: user!.notify_ticket_comments, ticket_assigned: user!.notify_ticket_assigned, + sso_status: config!.sso_active, }; reply.send({ @@ -282,6 +518,50 @@ export function authRoutes(fastify: FastifyInstance) { } ); + // Update a users Email notification settings + fastify.put( + "/api/v1/auth/profile/notifcations/emails", + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + + //checks if token is valid and returns valid token + const token = checkToken(bearer); + + if (token) { + let session = await prisma.session.findUnique({ + where: { + sessionToken: bearer, + }, + }); + + const { + notify_ticket_created, + notify_ticket_assigned, + notify_ticket_comments, + notify_ticket_status_changed, + } = request.body as any; + + let user = await prisma.user.update({ + where: { id: session?.userId }, + data: { + notify_ticket_created: notify_ticket_created, + notify_ticket_assigned: notify_ticket_assigned, + notify_ticket_comments: notify_ticket_comments, + notify_ticket_status_changed: notify_ticket_status_changed, + }, + }); + + reply.send({ + user, + }); + } else { + reply.send({ + sucess: false, + }); + } + } + ); + // Logout a user (deletes session) fastify.get( "/api/v1/auth/user/:id/logout", @@ -299,4 +579,25 @@ export function authRoutes(fastify: FastifyInstance) { } } ); + + // Update a users role + fastify.put( + "/api/v1/auth/user/role", + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + const token = checkToken(bearer); + if (token) { + const { id, role } = request.body as { id: string; role: boolean }; + + await prisma.user.update({ + where: { id }, + data: { + isAdmin: role, + }, + }); + + reply.send({ success: true }); + } + } + ); } diff --git a/apps/api/src/controllers/clients.ts b/apps/api/src/controllers/clients.ts index c3a9b63a5..d2c92adc5 100644 --- a/apps/api/src/controllers/clients.ts +++ b/apps/api/src/controllers/clients.ts @@ -1,4 +1,5 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { track } from "../lib/hog"; import { checkToken } from "../lib/jwt"; import { prisma } from "../prisma"; @@ -14,7 +15,7 @@ export function clientRoutes(fastify: FastifyInstance) { if (token) { const { name, email, number, contactName }: any = request.body; - await prisma.client.create({ + const client = await prisma.client.create({ data: { name, contactName, @@ -23,6 +24,13 @@ export function clientRoutes(fastify: FastifyInstance) { }, }); + const hog = track(); + + hog.capture({ + event: "client_created", + distinctId: client.id, + }); + reply.send({ success: true, }); diff --git a/apps/api/src/controllers/config.ts b/apps/api/src/controllers/config.ts new file mode 100644 index 000000000..9936d47d4 --- /dev/null +++ b/apps/api/src/controllers/config.ts @@ -0,0 +1,237 @@ +// Check Github Version +// Add outbound email provider +// Email Verification +// SSO Provider +// Portal Locale +// Feature Flags + +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +import { checkToken } from "../lib/jwt"; +import { prisma } from "../prisma"; + +export function configRoutes(fastify: FastifyInstance) { + // Check if SSO is enabled + fastify.get( + "/api/v1/config/sso/enabled", + + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + const token = checkToken(bearer); + + if (token) { + const config = await prisma.config.findFirst(); + + //@ts-expect-error + const { sso_active } = config; + + if (sso_active) { + const provider = await prisma.provider.findFirst({}); + + reply.send({ + success: true, + sso: sso_active, + provider: provider, + }); + } + + reply.send({ + success: true, + sso: sso_active, + }); + } + } + ); + + // Update SSO Provider Settings + fastify.post( + "/api/v1/config/sso/provider", + + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + const token = checkToken(bearer); + + if (token) { + const { + name, + client_id, + client_secret, + redirect_uri, + tenantId, + issuer, + }: any = request.body; + + const conf = await prisma.config.findFirst(); + + //update config to true + await prisma.config.update({ + where: { id: conf!.id }, + data: { + sso_active: true, + sso_provider: name, + }, + }); + + const check_provider = await prisma.provider.findFirst({}); + + if (check_provider === null) { + await prisma.provider.create({ + data: { + name: name, + clientId: client_id, + clientSecret: client_secret, + active: true, + redirectUri: redirect_uri, + tenantId: tenantId, + issuer: issuer, + }, + }); + } else { + await prisma.provider.update({ + where: { id: check_provider.id }, + data: { + name: name, + clientId: client_id, + clientSecret: client_secret, + active: true, + redirectUri: redirect_uri, + tenantId: tenantId, + issuer: issuer, + }, + }); + } + + reply.send({ + success: true, + message: "SSO Provider updated!", + }); + } + } + ); + + // Delete SSO Provider + fastify.delete( + "/api/v1/config/sso/provider", + + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + const token = checkToken(bearer); + + if (token) { + const conf = await prisma.config.findFirst(); + + //update config to true + await prisma.config.update({ + where: { id: conf!.id }, + data: { + sso_active: false, + sso_provider: "", + }, + }); + + const provider = await prisma.provider.findFirst({}); + await prisma.provider.delete({ + where: { id: provider!.id }, + }); + + reply.send({ + success: true, + message: "SSO Provider deleted!", + }); + } + } + ); + + // Check if Emails are enabled & GET email settings + fastify.get( + "/api/v1/config/email", + + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + const token = checkToken(bearer); + + if (token) { + // GET EMAIL SETTINGS + const config = await prisma.email.findFirst({}); + + if (config === null) { + reply.send({ + success: true, + active: false, + }); + } + + if (config?.active) { + reply.send({ + success: true, + active: true, + email: config, + }); + } else { + reply.send({ + success: true, + active: false, + email: config, + }); + } + } + } + ); + + // Update Email Provider Settings + fastify.put( + "/api/v1/config/email", + + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + const token = checkToken(bearer); + + if (token) { + const { + host, + active, + port, + reply: replyto, + username, + password, + }: any = request.body; + + const email = await prisma.email.findFirst(); + + if (email === null) { + await prisma.email.create({ + data: { + host: host, + port: port, + reply: replyto, + user: username, + pass: password, + active: true, + }, + }); + } else { + await prisma.email.update({ + where: { id: email.id }, + data: { + host: host, + port: port, + reply: replyto, + user: username, + pass: password, + active: active, + }, + }); + } + + reply.send({ + success: true, + message: "SSO Provider updated!", + }); + } + } + ); + + // Test email is working + + // Disable/Enable Email +} diff --git a/apps/api/src/controllers/notifications.ts b/apps/api/src/controllers/notifications.ts new file mode 100644 index 000000000..b5f9fe62d --- /dev/null +++ b/apps/api/src/controllers/notifications.ts @@ -0,0 +1,4 @@ +// Create a new notification +// Get All notifications +// Mark as read +// Delete notification diff --git a/apps/api/src/controllers/ticket.ts b/apps/api/src/controllers/ticket.ts index 327ee6fa0..cd3dc0725 100644 --- a/apps/api/src/controllers/ticket.ts +++ b/apps/api/src/controllers/ticket.ts @@ -2,9 +2,24 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import axios from "axios"; import { checkToken } from "../lib/jwt"; + +//@ts-ignore +import { track } from "../lib/hog"; +import { sendAssignedEmail } from "../lib/nodemailer/ticket/assigned"; +import { sendComment } from "../lib/nodemailer/ticket/comment"; +import { sendTicketCreate } from "../lib/nodemailer/ticket/create"; +import { sendTicketStatus } from "../lib/nodemailer/ticket/status"; import { checkSession } from "../lib/session"; import { prisma } from "../prisma"; +const validateEmail = (email: string) => { + return String(email) + .toLowerCase() + .match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + ); +}; + export function ticketRoutes(fastify: FastifyInstance) { // Create a new ticket fastify.post( @@ -47,6 +62,20 @@ export function ticketRoutes(fastify: FastifyInstance) { }, }); + if (!email && !validateEmail(email)) { + await sendTicketCreate(ticket); + } + + if (engineer && engineer.name !== "Unassigned") { + const assgined = await prisma.user.findUnique({ + where: { + id: ticket.userId, + }, + }); + + await sendAssignedEmail(assgined!.email); + } + const webhook = await prisma.webhooks.findMany({ where: { type: "ticket_created", @@ -55,19 +84,43 @@ export function ticketRoutes(fastify: FastifyInstance) { for (let i = 0; i < webhook.length; i++) { if (webhook[i].active === true) { - console.log(webhook[i].url); - await axios.post(`${webhook[i].url}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - data: `Ticket ${ticket.id} created by ${ticket.name} -> ${ticket.email}. Priority -> ${ticket.priority}`, - }), - }); + const url = webhook[i].url; + if (url.includes("discord.com")) { + const message = { + content: `Ticket ${ticket.id} created by ${ticket.name} -> ${ticket.email}. Priority -> ${ticket.priority}`, + avatar_url: + "https://avatars.githubusercontent.com/u/76014454?s=200&v=4", + username: "Peppermint.sh", + }; + axios + .post(url, message) + .then((response) => { + console.log("Message sent successfully!"); + console.log("Discord API response:", response.data); + }) + .catch((error) => { + console.error("Error sending message:", error); + }); + } else { + await axios.post(`${webhook[i].url}`, { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: `Ticket ${ticket.id} created by ${ticket.name} -> ${ticket.email}. Priority -> ${ticket.priority}`, + }), + }); + } } } + const hog = track(); + + hog.capture({ + event: "ticket_created", + distinctId: ticket.id, + }); + reply.status(200).send({ message: "Ticket created correctly", success: true, @@ -177,6 +230,41 @@ export function ticketRoutes(fastify: FastifyInstance) { } ); + // Get all tickets (admin) + fastify.get( + "/api/v1/tickets/all/admin", + async (request: FastifyRequest, reply: FastifyReply) => { + const bearer = request.headers.authorization!.split(" ")[1]; + const token = checkToken(bearer); + + if (token) { + const tickets = await prisma.ticket.findMany({ + orderBy: [ + { + createdAt: "desc", + }, + ], + include: { + client: { + select: { id: true, name: true, number: true }, + }, + assignedTo: { + select: { id: true, name: true }, + }, + team: { + select: { id: true, name: true }, + }, + }, + }); + + reply.send({ + tickets: tickets, + sucess: true, + }); + } + } + ); + // Get all open tickets for a user fastify.get( "/api/v1/tickets/user/open", @@ -308,7 +396,7 @@ export function ticketRoutes(fastify: FastifyInstance) { const { user, id }: any = request.body; if (token) { - await prisma.user.update({ + const assigned = await prisma.user.update({ where: { id: user }, data: { tickets: { @@ -319,6 +407,10 @@ export function ticketRoutes(fastify: FastifyInstance) { }, }); + const { email } = assigned; + + await sendAssignedEmail(email); + reply.send({ success: true, }); @@ -327,6 +419,7 @@ export function ticketRoutes(fastify: FastifyInstance) { ); // Link a ticket to another ticket + // fastify.post( // "/api/v1/ticket/link", @@ -378,7 +471,7 @@ export function ticketRoutes(fastify: FastifyInstance) { const bearer = request.headers.authorization!.split(" ")[1]; const token = checkToken(bearer); - const { text, id }: any = request.body; + const { text, id, public: public_comment }: any = request.body; if (token) { const user = await checkSession(bearer); @@ -386,12 +479,32 @@ export function ticketRoutes(fastify: FastifyInstance) { await prisma.comment.create({ data: { text: text, - public: Boolean(false), + public: public_comment, ticketId: id, userId: user!.id, }, }); + const ticket = await prisma.ticket.findUnique({ + where: { + id: id, + }, + }); + + //@ts-expect-error + const { email, title } = ticket; + + if (public_comment && email) { + sendComment(text, title, email); + } + + const hog = track(); + + hog.capture({ + event: "ticket_comment", + distinctId: ticket!.id, + }); + reply.send({ success: true, }); @@ -410,16 +523,12 @@ export function ticketRoutes(fastify: FastifyInstance) { if (token) { const { status, id }: any = request.body; - const ticket: any = await prisma.ticket - .update({ - where: { id: id }, - data: { - isComplete: status, - }, - }) - .then(async (ticket) => { - // await sendTicketStatus(ticket); - }); + const ticket: any = await prisma.ticket.update({ + where: { id: id }, + data: { + isComplete: status, + }, + }); const webhook = await prisma.webhooks.findMany({ where: { @@ -428,21 +537,41 @@ export function ticketRoutes(fastify: FastifyInstance) { }); for (let i = 0; i < webhook.length; i++) { + const url = webhook[i].url; + if (webhook[i].active === true) { const s = status ? "Completed" : "Outstanding"; - await axios.post(`${webhook[i].url}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - data: `Ticket ${ticket.id} created by ${ticket.email}, has had it's status changed to ${s}`, - }), - redirect: "follow", - }); + if (url.includes("discord.com")) { + const message = { + content: `Ticket ${ticket.id} created by ${ticket.email}, has had it's status changed to ${s}`, + avatar_url: + "https://avatars.githubusercontent.com/u/76014454?s=200&v=4", + username: "Peppermint.sh", + }; + axios + .post(url, message) + .then((response) => { + console.log("Message sent successfully!"); + console.log("Discord API response:", response.data); + }) + .catch((error) => { + console.error("Error sending message:", error); + }); + } else { + await axios.post(`${webhook[i].url}`, { + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + data: `Ticket ${ticket.id} created by ${ticket.email}, has had it's status changed to ${s}`, + }), + }); + } } } + sendTicketStatus(ticket); + reply.send({ success: true, }); diff --git a/apps/api/src/controllers/time.ts b/apps/api/src/controllers/time.ts index 463c561ee..b1a23742f 100644 --- a/apps/api/src/controllers/time.ts +++ b/apps/api/src/controllers/time.ts @@ -1,19 +1,32 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { prisma } from "../prisma"; export function timeTrackingRoutes(fastify: FastifyInstance) { // Create a new entry - fastify.get( - "/api/v1/time/entries/new", + fastify.post( + "/api/v1/time/new", async (request: FastifyRequest, reply: FastifyReply) => { - // check jwt is valid - // check user is admin + const { time, ticket, title, user }: any = request.body; + + console.log(request.body); + + await prisma.timeTracking.create({ + data: { + time: Number(time), + title, + userId: user, + ticketId: ticket, + }, + }); + + reply.send({ + success: true, + }); } ); // Get all entries // Delete an entry - - // Link an entry to a ticket } diff --git a/apps/api/src/lib/hog.ts b/apps/api/src/lib/hog.ts new file mode 100644 index 000000000..ba543c993 --- /dev/null +++ b/apps/api/src/lib/hog.ts @@ -0,0 +1,9 @@ +import { PostHog } from "posthog-node"; + +export function track() { + return new PostHog( + "phc_2gbpy3JPtDC6hHrQy35yMxMci1NY0fD1sttGTcPjwVf", + + { host: "https://app.posthog.com" } + ); +} diff --git a/apps/api/src/lib/imap.ts b/apps/api/src/lib/imap.ts index 159083fd1..f1b882e25 100644 --- a/apps/api/src/lib/imap.ts +++ b/apps/api/src/lib/imap.ts @@ -85,31 +85,43 @@ export const getEmails = async () => { simpleParser(stream, async (err: any, parsed: any) => { const { from, subject, textAsHtml, text, html } = parsed; + console.log("from", from); + const parsedData = parseEmailContent(textAsHtml); - const imap = await client.imap_Email.create({ - data: { - from: from.text, - subject: subject ? subject : "No Subject", - body: text ? text : "No Body", - html: html ? html : "", - text: textAsHtml, - }, - }); - - const ticket = await client.ticket.create({ - data: { - email: imap.from, - name: imap.from, - title: imap.subject ? imap.subject : "-", - isComplete: Boolean(false), - priority: "Low", - fromImap: Boolean(true), - detail: textAsHtml, - }, - }); - - console.log(imap, ticket); + if (subject !== undefined && subject.includes("Re:")) { + return await client.comment.create({ + data: { + text: text ? text : "No Body", + userId: null, + ticketId: subject.split(" ")[1].split("#")[1], + }, + }); + } else { + const imap = await client.imap_Email.create({ + data: { + from: from.value[0].address, + subject: subject ? subject : "No Subject", + body: text ? text : "No Body", + html: html ? html : "", + text: textAsHtml, + }, + }); + + const ticket = await client.ticket.create({ + data: { + email: from.value[0].address, + name: from.value[0].name, + title: imap.subject ? imap.subject : "-", + isComplete: Boolean(false), + priority: "Low", + fromImap: Boolean(true), + detail: textAsHtml, + }, + }); + + console.log(imap, ticket); + } }); }); msg.once("attributes", (attrs: any) => { diff --git a/apps/api/src/lib/nodemailer/auth/forgot-password.ts b/apps/api/src/lib/nodemailer/auth/forgot-password.ts new file mode 100644 index 000000000..03e242cd4 --- /dev/null +++ b/apps/api/src/lib/nodemailer/auth/forgot-password.ts @@ -0,0 +1,98 @@ +import nodeMailer from "nodemailer"; +import { prisma } from "../../../prisma"; + +export async function forgotPassword( + email: string, + code: string, + link: string, + token: string +) { + try { + let mail; + + const emails = await prisma.email.findMany(); + + const resetlink = `${link}/auth/reset-password?token=${token}`; + + if (emails.length > 0) { + if (process.env.ENVIRONMENT === "development") { + let testAccount = await nodeMailer.createTestAccount(); + mail = nodeMailer.createTransport({ + port: 1025, + secure: false, // true for 465, false for other ports + auth: { + user: testAccount.user, // generated ethereal user + pass: testAccount.pass, // generated ethereal password + }, + }); + } else { + const email = emails[0]; + mail = nodeMailer.createTransport({ + // @ts-ignore + host: email.host, + port: email.port, + secure: email.secure, // true for 465, false for other ports + auth: { + user: email.user, // generated ethereal user + pass: email.pass, // generated ethereal password + }, + }); + } + + console.log("Sending email to: ", email); + + let info = await mail.sendMail({ + from: "noreply@peppermint.sh", // sender address + to: email, // list of receivers + subject: `Password Reset Request`, // Subject line + text: `Password Reset Code: ${code}, follow this link to reset your password ${resetlink}`, // plain text body + html: ` + + + + + +
Ticket Created
+
+ + + + + + +
+ +
+

Password Reset

+

+

Password code: ${code}

+ Reset Here +

+ Kind regards, + + + + + + + +
+ Our blog | Documentation | Discord +

This was an automated message sent by peppermint.sh -> An open source helpdesk solution

+

©2022 Peppermint Ticket Management, a Peppermint Labs product.
All rights reserved.

+
+

+ + + `, + }); + + console.log("Message sent: %s", info.messageId); + + // Preview only available when sending through an Ethereal account + console.log("Preview URL: %s", nodeMailer.getTestMessageUrl(info)); + } + } catch (error) { + console.log(error); + } +} diff --git a/apps/api/src/lib/nodemailer/ticket/assigned.ts b/apps/api/src/lib/nodemailer/ticket/assigned.ts new file mode 100644 index 000000000..213174491 --- /dev/null +++ b/apps/api/src/lib/nodemailer/ticket/assigned.ts @@ -0,0 +1,90 @@ +import nodeMailer from "nodemailer"; +import { prisma } from "../../../prisma"; + +export async function sendAssignedEmail(email: any) { + try { + let mail; + + const emails = await prisma.email.findMany(); + + if (emails.length > 0) { + if (process.env.ENVIRONMENT === "development") { + let testAccount = await nodeMailer.createTestAccount(); + mail = nodeMailer.createTransport({ + port: 1025, + secure: false, // true for 465, false for other ports + auth: { + user: testAccount.user, // generated ethereal user + pass: testAccount.pass, // generated ethereal password + }, + }); + } else { + const email = emails[0]; + mail = nodeMailer.createTransport({ + // @ts-ignore + host: email.host, + port: email.port, + secure: email.secure, // true for 465, false for other ports + auth: { + user: email.user, // generated ethereal user + pass: email.pass, // generated ethereal password + }, + }); + } + + console.log("Sending email to: ", email); + + await mail + .sendMail({ + from: "noreply@peppermint.sh", // sender address + to: email, // list of receivers + subject: `A new ticket has been assigned to you`, // Subject line + text: `Hello there, a ticket has been assigned to you`, // plain text body + html: ` + + + + + +
Ticket Created
+
+ + + + + + +
+ +
+

Ticket Assigned

+

+

Hello,
A new ticket has been assigned to you.

+

+ Kind regards, + + + + + + + +
+ Documentation | Discord +

This was an automated message sent by peppermint.sh -> An open source helpdesk solution

+

©2022 Peppermint Ticket Management, a Peppermint Labs product.
All rights reserved.

+
+

+ + + `, + }) + .then((info) => { + console.log("Message sent: %s", info.messageId); + }) + .catch((err) => console.log(err)); + } + } catch (error) { + console.log(error); + } +} diff --git a/apps/api/src/lib/nodemailer/ticket/comment.ts b/apps/api/src/lib/nodemailer/ticket/comment.ts new file mode 100644 index 000000000..c730d2258 --- /dev/null +++ b/apps/api/src/lib/nodemailer/ticket/comment.ts @@ -0,0 +1,96 @@ +import nodeMailer from "nodemailer"; +import { prisma } from "../../../prisma"; + +export async function sendComment( + comment: string, + title: string, + email: string +) { + try { + let mail; + + console.log("Sending email to: ", email); + + const emails = await prisma.email.findMany(); + + if (emails.length > 0) { + if (process.env.ENVIRONMENT === "development") { + let testAccount = await nodeMailer.createTestAccount(); + mail = nodeMailer.createTransport({ + port: 1025, + secure: false, // true for 465, false for other ports + auth: { + user: testAccount.user, // generated ethereal user + pass: testAccount.pass, // generated ethereal password + }, + }); + } else { + const email = emails[0]; + mail = nodeMailer.createTransport({ + // @ts-ignore + host: email.host, + port: email.port, + secure: email.secure, // true for 465, false for other ports + auth: { + user: email.user, // generated ethereal user + pass: email.pass, // generated ethereal password + }, + }); + } + + await mail + .sendMail({ + from: '"No reply 👻" noreply@peppermint.sh', // sender address + to: email, + subject: `New comment on a ticket`, // Subject line + text: `Hello there, Ticket: ${title}, has had an update with a comment of ${comment}`, // plain text body + html: ` + + + + + + +
Ticket Created
+
+ + + + + + +
+ +
+

Ticket Update for: ${title}

+

+

${comment}

+

+ Kind regards, + + + + + + + +
+ Documentation | Discord +

This was an automated message sent by peppermint.sh -> An open source helpdesk solution

+

©2022 Peppermint Ticket Management, a Peppermint Labs product.
All rights reserved.

+
+

+ + + + `, + }) + .then((info) => { + console.log("Message sent: %s", info.messageId); + }) + .catch((err) => console.log(err)); + } + } catch (error) { + console.log(error); + } +} diff --git a/apps/api/src/lib/nodemailer/ticket/create.ts b/apps/api/src/lib/nodemailer/ticket/create.ts new file mode 100644 index 000000000..2885064a5 --- /dev/null +++ b/apps/api/src/lib/nodemailer/ticket/create.ts @@ -0,0 +1,90 @@ +import nodeMailer from "nodemailer"; +import { prisma } from "../../../prisma"; + +export async function sendTicketCreate(ticket: any) { + try { + let mail; + + const emails = await prisma.email.findMany(); + + if (emails.length > 0) { + if (process.env.ENVIRONMENT === "development") { + let testAccount = await nodeMailer.createTestAccount(); + mail = nodeMailer.createTransport({ + port: 1025, + secure: false, // true for 465, false for other ports + auth: { + user: testAccount.user, // generated ethereal user + pass: testAccount.pass, // generated ethereal password + }, + }); + } else { + const email = emails[0]; + mail = nodeMailer.createTransport({ + // @ts-ignore + host: email.host, + port: email.port, + secure: email.secure, // true for 465, false for other ports + auth: { + user: email.user, // generated ethereal user + pass: email.pass, // generated ethereal password + }, + }); + } + + await mail + .sendMail({ + from: '"No reply 👻" noreply@peppermint.sh', // sender address + to: ticket.email, + subject: `Ticket ${ticket.id} has just been created & logged`, // Subject line + text: `Hello there, Ticket ${ticket.id}, which you reported on ${ticket.createdAt}, has now been created and logged`, // plain text body + html: ` + + + + + + +
Ticket Created
+
+ + + + + + +
+ +
+

Ticket Created: ${ticket.id}

+

+

Hello,
Your ticket has now been created and logged.

+

+ Kind regards, + + + + + + + +
+ Documentation | Discord +

This was an automated message sent by peppermint.sh -> An open source helpdesk solution

+

©2022 Peppermint Ticket Management, a Peppermint Labs product.
All rights reserved.

+
+

+ + + + `, + }) + .then((info) => { + console.log("Message sent: %s", info.messageId); + }) + .catch((err) => console.log(err)); + } + } catch (error) { + console.log(error); + } +} diff --git a/apps/api/src/lib/nodemailer/ticket/status.ts b/apps/api/src/lib/nodemailer/ticket/status.ts new file mode 100644 index 000000000..a75fa9b2f --- /dev/null +++ b/apps/api/src/lib/nodemailer/ticket/status.ts @@ -0,0 +1,101 @@ +import nodeMailer from "nodemailer"; +import { prisma } from "../../../prisma"; + +export async function sendTicketStatus(ticket: any) { + let mail; + + const emails = await prisma.email.findMany(); + + if (emails.length > 0) { + if (process.env.ENVIRONMENT === "development") { + let testAccount = await nodeMailer.createTestAccount(); + mail = nodeMailer.createTransport({ + port: 1025, + secure: false, // true for 465, false for other ports + auth: { + user: testAccount.user, // generated ethereal user + pass: testAccount.pass, // generated ethereal password + }, + }); + } else { + const email = emails[0]; + mail = nodeMailer.createTransport({ + //@ts-ignore + host: email.host, + port: email.port, + secure: email.secure, // true for 465, false for other ports + auth: { + user: email.user, // generated ethereal user + pass: email.pass, // generated ethereal password + }, + }); + } + + await mail + .sendMail({ + from: "noreply@peppermint.sh", // sender address + to: ticket.email, + subject: `Ticket ${ticket.id} status is now ${ + ticket.isComplete ? "COMPLETED" : "OUTSTANDING" + }`, // Subject line + text: `Hello there, Ticket ${ticket.id}, now has a status of ${ + ticket.isComplete ? "COMPLETED" : "OUTSTANDING" + }`, // plain text body + html: ` + + + + + + +
Ticket Created
+
+ + + + + + +
+ + + + + + +
Slack
+

Ticket: ${ + ticket.title + }

+

+

Your Ticket, now has a status of ${ + ticket.isComplete ? "completed" : "open" + }

+ Kind regards, +
+ Peppermint ticket management +

+ + + + + + + +
+ Documentation | Discord +

This was an automated message sent by peppermint.sh -> An open source helpdesk solution

+

©2022 Peppermint Ticket Management, a Peppermint Labs product.
All rights reserved.

+
+
+ + + + `, + }) + .then((info) => { + console.log("Message sent: %s", info.messageId); + }) + .catch((err) => console.log(err)); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 4405a4e45..d48f70c13 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -4,7 +4,7 @@ import Fastify, { FastifyInstance } from "fastify"; import { getEmails } from "./lib/imap"; import { exec } from "child_process"; -import { PostHog } from "posthog-node"; +import { track } from "./lib/hog"; import { prisma } from "./prisma"; import { registerRoutes } from "./routes"; @@ -112,7 +112,7 @@ const start = async () => { const port = process.env.PORT || 5003; - await server.listen( + server.listen( { port: Number(port), host: "0.0.0.0" }, async (err, address) => { if (err) { @@ -120,18 +120,14 @@ const start = async () => { process.exit(1); } - const client = new PostHog( - "phc_2gbpy3JPtDC6hHrQy35yMxMci1NY0fD1sttGTcPjwVf", - - { host: "https://app.posthog.com" } - ); + const client = track(); client.capture({ event: "server_started", distinctId: "uuid", }); - await client.shutdownAsync(); + client.shutdownAsync(); console.info(`Server listening on ${address}`); } ); diff --git a/apps/api/src/prisma/migrations/20231130184144_uptime_kb_config/migration.sql b/apps/api/src/prisma/migrations/20231130184144_uptime_kb_config/migration.sql new file mode 100644 index 000000000..800cbf950 --- /dev/null +++ b/apps/api/src/prisma/migrations/20231130184144_uptime_kb_config/migration.sql @@ -0,0 +1,59 @@ +-- AlterTable +ALTER TABLE "Config" ADD COLUMN "client_version" TEXT, +ADD COLUMN "feature_previews" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "gh_version" TEXT, +ADD COLUMN "out_of_office" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "out_of_office_end" TIMESTAMP(3), +ADD COLUMN "out_of_office_message" TEXT, +ADD COLUMN "out_of_office_start" TIMESTAMP(3), +ADD COLUMN "portal_locale" TEXT, +ADD COLUMN "sso_active" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "sso_provider" TEXT; + +-- CreateTable +CREATE TABLE "SSO" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "clientSecret" TEXT NOT NULL, + "active" BOOLEAN NOT NULL, + "issuer" TEXT, + "tenantId" TEXT, + + CONSTRAINT "SSO_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Uptime" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT false, + "webhook" TEXT, + "latency" INTEGER, + "status" BOOLEAN, + + CONSTRAINT "Uptime_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "knowledgeBase" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "tags" TEXT[], + "author" TEXT NOT NULL, + "public" BOOLEAN NOT NULL DEFAULT false, + "ticketId" TEXT, + + CONSTRAINT "knowledgeBase_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "knowledgeBase" ADD CONSTRAINT "knowledgeBase_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20231130184716_sso/migration.sql b/apps/api/src/prisma/migrations/20231130184716_sso/migration.sql new file mode 100644 index 000000000..216e9b680 --- /dev/null +++ b/apps/api/src/prisma/migrations/20231130184716_sso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the `SSO` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "SSO"; diff --git a/apps/api/src/prisma/migrations/20231130185305_allow/migration.sql b/apps/api/src/prisma/migrations/20231130185305_allow/migration.sql new file mode 100644 index 000000000..7adf3b10d --- /dev/null +++ b/apps/api/src/prisma/migrations/20231130185305_allow/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Config" ALTER COLUMN "notifications" DROP NOT NULL; diff --git a/apps/api/src/prisma/migrations/20231201011858_redirect/migration.sql b/apps/api/src/prisma/migrations/20231201011858_redirect/migration.sql new file mode 100644 index 000000000..0429f1b5a --- /dev/null +++ b/apps/api/src/prisma/migrations/20231201011858_redirect/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the `Account` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `VerificationToken` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Account" DROP CONSTRAINT "Account_userId_fkey"; + +-- AlterTable +ALTER TABLE "Provider" ADD COLUMN "redirectUri" TEXT; + +-- DropTable +DROP TABLE "Account"; + +-- DropTable +DROP TABLE "VerificationToken"; diff --git a/apps/api/src/prisma/migrations/20231202030625_/migration.sql b/apps/api/src/prisma/migrations/20231202030625_/migration.sql new file mode 100644 index 000000000..c0f80f7db --- /dev/null +++ b/apps/api/src/prisma/migrations/20231202030625_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Config" ADD COLUMN "encryption_key" TEXT; diff --git a/apps/api/src/prisma/migrations/20231202030940_/migration.sql b/apps/api/src/prisma/migrations/20231202030940_/migration.sql new file mode 100644 index 000000000..f32bebc27 --- /dev/null +++ b/apps/api/src/prisma/migrations/20231202030940_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Config" ADD COLUMN "first_time_setup" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/src/prisma/migrations/20231202031821_/migration.sql b/apps/api/src/prisma/migrations/20231202031821_/migration.sql new file mode 100644 index 000000000..e4610340a --- /dev/null +++ b/apps/api/src/prisma/migrations/20231202031821_/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - The `encryption_key` column on the `Config` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- AlterTable +ALTER TABLE "Config" DROP COLUMN "encryption_key", +ADD COLUMN "encryption_key" BYTEA; diff --git a/apps/api/src/prisma/migrations/20231203164241_/migration.sql b/apps/api/src/prisma/migrations/20231203164241_/migration.sql new file mode 100644 index 000000000..5e32c2625 --- /dev/null +++ b/apps/api/src/prisma/migrations/20231203164241_/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Comment" DROP CONSTRAINT "Comment_userId_fkey"; + +-- AlterTable +ALTER TABLE "Comment" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20231203224035_code/migration.sql b/apps/api/src/prisma/migrations/20231203224035_code/migration.sql new file mode 100644 index 000000000..b29e5f0a3 --- /dev/null +++ b/apps/api/src/prisma/migrations/20231203224035_code/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_code_key" ON "PasswordResetToken"("code"); diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 9e1226fc5..e45d759b6 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -15,26 +15,7 @@ model Provider { active Boolean issuer String? tenantId String? -} - -model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? - refresh_token_expires_in Int? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) + redirectUri String? } model Session { @@ -45,6 +26,12 @@ model Session { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model PasswordResetToken { + id String @id @default(cuid()) + code String @unique + userId String +} + model User { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -69,19 +56,10 @@ model User { Team Team? @relation(fields: [teamId], references: [id]) teamId String? Comment Comment[] - Account Account[] Session Session[] TimeTracking TimeTracking[] } -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) -} - model Team { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -114,12 +92,13 @@ model Ticket { Comment Comment[] TimeTracking TimeTracking[] - team Team? @relation(fields: [teamId], references: [id]) - teamId String? - assignedTo User? @relation(fields: [userId], references: [id]) - client Client? @relation(fields: [clientId], references: [id]) - clientId String? - userId String? + team Team? @relation(fields: [teamId], references: [id]) + teamId String? + assignedTo User? @relation(fields: [userId], references: [id]) + client Client? @relation(fields: [clientId], references: [id]) + clientId String? + userId String? + knowledgeBase knowledgeBase[] } model TimeTracking { @@ -145,10 +124,10 @@ model Comment { text String public Boolean @default(false) - userId String - user User @relation(fields: [userId], references: [id]) + userId String? + user User? @relation(fields: [userId], references: [id]) ticketId String - ticket Ticket @relation(fields: [ticketId], references: [id]) + ticket Ticket @relation(fields: [ticketId], references: [id]) } model Client { @@ -258,7 +237,47 @@ model Config { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) - notifications Json // Array of service names and an active field of TRUE OR FALSE + notifications Json? // Array of service names and an active field of TRUE OR FALSE + sso_provider String? + sso_active Boolean @default(false) + gh_version String? + client_version String? + portal_locale String? + feature_previews Boolean @default(false) + out_of_office Boolean @default(false) + out_of_office_message String? + out_of_office_start DateTime? + out_of_office_end DateTime? + encryption_key Bytes? + first_time_setup Boolean @default(true) +} + +model Uptime { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + + name String + url String + active Boolean @default(false) + webhook String? + latency Int? + status Boolean? +} + +model knowledgeBase { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + + title String + content String + tags String[] + author String + public Boolean @default(false) + + ticketId String? + ticket Ticket? @relation(fields: [ticketId], references: [id]) } model Imap_Email { diff --git a/apps/api/src/prisma/seed.js b/apps/api/src/prisma/seed.js index 647e68276..f43a4aa17 100644 --- a/apps/api/src/prisma/seed.js +++ b/apps/api/src/prisma/seed.js @@ -1,33 +1,59 @@ const { PrismaClient } = require("@prisma/client"); +const crypto = require("crypto"); const prisma = new PrismaClient(); async function main() { - const admin = await prisma.user.upsert({ - where: { email: "admin@admin.com" }, - update: {}, - create: { - email: `admin@admin.com`, - name: "admin", - isAdmin: true, - password: "$2b$10$BFmibvOW7FtY0soAAwujoO9y2tIyB7WEJ2HNq9O7zh9aeejMvRsKu", - language: "en", - }, - }); + const setup = await prisma.config.findFirst({}); - const internal = await prisma.client.upsert({ - where: { email: `internal@admin.com` }, - update: {}, - create: { - email: `internal@admin.com`, - name: "internal", - contactName: "admin", - number: "123456789", - active: true, - }, - }); + if (setup === null) { + await prisma.user.upsert({ + where: { email: "admin@admin.com" }, + update: {}, + create: { + email: `admin@admin.com`, + name: "admin", + isAdmin: true, + password: + "$2b$10$BFmibvOW7FtY0soAAwujoO9y2tIyB7WEJ2HNq9O7zh9aeejMvRsKu", + language: "en", + }, + }); + + await prisma.client.upsert({ + where: { email: `internal@admin.com` }, + update: {}, + create: { + email: `internal@admin.com`, + name: "internal", + contactName: "admin", + number: "123456789", + active: true, + }, + }); + + const encryptionKey = crypto.randomBytes(32); // Generates a random key + + const conf = await prisma.config.create({ + data: { + gh_version: "0.3.6", + client_version: "0.3.6", + portal_locale: "en", + encryption_key: encryptionKey, + }, + }); - console.log({ admin, internal }); + await prisma.config.update({ + where: { + id: conf.id, + }, + data: { + first_time_setup: false, + }, + }); + } else { + console.log("No need to seed, already seeded"); + } } main() diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index a342a457f..446be6441 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -1,10 +1,12 @@ import { FastifyInstance } from "fastify"; import { authRoutes } from "./controllers/auth"; import { clientRoutes } from "./controllers/clients"; +import { configRoutes } from "./controllers/config"; import { dataRoutes } from "./controllers/data"; import { notebookRoutes } from "./controllers/notebook"; import { emailQueueRoutes } from "./controllers/queue"; import { ticketRoutes } from "./controllers/ticket"; +import { timeTrackingRoutes } from "./controllers/time"; import { todoRoutes } from "./controllers/todos"; import { userRoutes } from "./controllers/users"; import { webhookRoutes } from "./controllers/webhooks"; @@ -19,4 +21,6 @@ export function registerRoutes(fastify: FastifyInstance) { notebookRoutes(fastify); clientRoutes(fastify); webhookRoutes(fastify); + configRoutes(fastify); + timeTrackingRoutes(fastify); } diff --git a/apps/client/components/TicketViews/admin.tsx b/apps/client/components/TicketViews/admin.tsx new file mode 100644 index 000000000..e59b3c734 --- /dev/null +++ b/apps/client/components/TicketViews/admin.tsx @@ -0,0 +1,377 @@ +import { getCookie } from "cookies-next"; +import moment from "moment"; +import { useRouter } from "next/router"; +import React, { useMemo } from "react"; +import { useQuery } from "react-query"; +import { + useFilters, + useGlobalFilter, + usePagination, + useTable, +} from "react-table"; +import TicketsMobileList from "../../components/TicketsMobileList"; + +const fetchALLTIckets = async () => { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/tickets/all/admin`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${getCookie("session")}`, + }, + } + ); + return res.json(); +}; + +function DefaultColumnFilter({ column: { filterValue, setFilter } }: any) { + return ( + { + setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely + }} + placeholder="Type to filter" + /> + ); +} +function Table({ columns, data }: any) { + const filterTypes = React.useMemo( + () => ({ + // Add a new fuzzyTextFilterFn filter type. + // fuzzyText: fuzzyTextFilterFn, + // Or, override the default text filter to use + // "startWith" + text: (rows: any, id: any, filterValue: any) => + rows.filter((row: any) => { + const rowValue = row.values[id]; + return rowValue !== undefined + ? String(rowValue) + .toLowerCase() + .startsWith(String(filterValue).toLowerCase()) + : true; + }), + }), + [] + ); + + const defaultColumn = React.useMemo( + () => ({ + // Let's set up our default Filter UI + Filter: DefaultColumnFilter, + }), + [] + ); + + const { + getTableProps, + getTableBodyProps, + headerGroups, + //@ts-expect-error + page, + prepareRow, + //@ts-expect-error + canPreviousPage, + //@ts-expect-error + canNextPage, + //@ts-expect-error + pageCount, + //@ts-expect-error + gotoPage, + //@ts-expect-error + nextPage, + //@ts-expect-error + previousPage, + //@ts-expect-error + setPageSize, + //@ts-expect-error + state: { pageIndex, pageSize }, + } = useTable( + { + columns, + data, + //@ts-expect-error + defaultColumn, // Be sure to pass the defaultColumn option + filterTypes, + initialState: { + //@ts-expect-error + pageIndex: 0, + }, + }, + useFilters, // useFilters! + useGlobalFilter, + usePagination + ); + + return ( +
+
+
+ + + {headerGroups.map((headerGroup: any) => ( + header.id)} + > + {headerGroup.headers.map((column: any) => + column.hideHeader === false ? null : ( + + ) + )} + + ))} + + + {page.map((row: any, i: any) => { + prepareRow(row); + return ( + + {row.cells.map((cell: any) => ( + + ))} + + ); + })} + +
+ {column.render("Header")} + {/* Render the columns filter UI */} +
+ {column.canFilter ? column.render("Filter") : null} +
+
+ {cell.render("Cell")} +
+ + {data.length > 10 && ( + + )} +
+
+
+ ); +} + +export default function AdminTicketLayout() { + const { data, status, refetch } = useQuery( + "fetchallTickets", + fetchALLTIckets + ); + + const router = useRouter(); + + const high = "bg-red-100 text-red-800"; + const low = "bg-blue-100 text-blue-800"; + const normal = "bg-green-100 text-green-800"; + + const columns = useMemo( + () => [ + { + Header: "Type", + accessor: "type", + id: "type", + width: 50, + }, + { + Header: "Summary", + accessor: "title", + id: "summary", + Cell: ({ row, value }: any) => { + return ( + <> + {value} + + ); + }, + }, + { + Header: "Assignee", + accessor: "assignedTo.name", + id: "assignee", + Cell: ({ row, value }: any) => { + return ( + <> + {value ? value : "n/a"} + + ); + }, + }, + { + Header: "Client", + accessor: "client.name", + id: "client", + Cell: ({ row, value }: any) => { + return ( + <> + {value ? value : "n/a"} + + ); + }, + }, + { + Header: "Priority", + accessor: "priority", + id: "priority", + Cell: ({ row, value }) => { + let p = value; + let badge; + + if (p === "Low") { + badge = low; + } + if (p === "Normal") { + badge = normal; + } + if (p === "High") { + badge = high; + } + + return ( + <> + + {value} + + + ); + }, + }, + { + Header: "Status", + accessor: "status", + id: "status", + Cell: ({ row, value }) => { + let p = value; + let badge; + + return ( + <> + + {value === "needs_support" && Needs Support} + {value === "in_progress" && In Progress} + {value === "in_review" && In Review} + {value === "done" && Done} + + + ); + }, + }, + { + Header: "Created", + accessor: "createdAt", + id: "created", + Cell: ({ row, value }) => { + const now = moment(value).format("DD/MM/YYYY"); + return ( + <> + {now} + + ); + }, + }, + ], + [] + ); + + return ( + <> + {status === "success" && ( + <> + {data.tickets && data.tickets.length > 0 && ( + <> +
+ + + +
+ +
+ + )} + + {data.tickets.length === 0 && ( + <> +
+ + + + +

+ You currently don't have any assigned tickets. :) +

+
+ + )} + + )} + + ); +} diff --git a/apps/client/components/UpdateUserModal/index.js b/apps/client/components/UpdateUserModal/index.tsx similarity index 66% rename from apps/client/components/UpdateUserModal/index.js rename to apps/client/components/UpdateUserModal/index.tsx index baf67ff17..d8f62daec 100644 --- a/apps/client/components/UpdateUserModal/index.js +++ b/apps/client/components/UpdateUserModal/index.tsx @@ -1,39 +1,29 @@ import { Dialog, Transition } from "@headlessui/react"; import { XMarkIcon } from "@heroicons/react/24/outline"; +import { getCookie } from "cookies-next"; import { useRouter } from "next/router"; -import React, { Fragment, useState } from "react"; +import { Fragment, useState } from "react"; export default function UpdateUserModal({ user }) { const [open, setOpen] = useState(false); - const [name, setName] = useState(user.name); - const [email, setEmail] = useState(user.email); const [admin, setAdmin] = useState(user.isAdmin); - const [error, setError] = useState(null); const router = useRouter(); - const notificationMethods = [ - { id: "user", title: "user" }, - { id: "admin", title: "admin" }, - ]; - async function updateUser() { - if (name.length > 0 && email.length > 0) { - await fetch("/api/v1/admin/user/update", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - name, - admin, - id: user.id, - }), - }).then(() => router.reload(router.pathname)); - } - setError("Length needs to be more than zero"); + await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/auth/user/role`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${getCookie("session")}`, + }, + body: JSON.stringify({ + role: admin, + id: user.id, + }), + }); + // .then(() => router.reload()); } return ( @@ -43,7 +33,7 @@ export default function UpdateUserModal({ user }) { type="button" className="inline-flex items-center px-4 py-1.5 border font-semibold border-gray-300 shadow-sm text-xs rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > - Edit + Role @@ -97,54 +87,10 @@ export default function UpdateUserModal({ user }) { as="h3" className="text-lg leading-6 font-medium text-gray-900" > - Edit User + Edit User Role
- <> - setName(e.target.value)} - value={name} - focus={ - error !== null && name.length > 0 ? true : false - } - /> - {error !== null && name.length === 0 && ( -

{error}

- )} - - - <> - setEmail(e.target.value)} - value={email} - focus={ - error !== null && email.length > 0 ? true : false - } - /> - {error !== null && email.length === 0 && ( -

{error}

- )} - -
-
diff --git a/apps/client/layouts/settings.tsx b/apps/client/layouts/settings.tsx index 3f4f4190e..b5be9a85e 100644 --- a/apps/client/layouts/settings.tsx +++ b/apps/client/layouts/settings.tsx @@ -1,13 +1,13 @@ -import { useState } from "react"; - import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; +import { useUser } from "../store/session"; export default function Settings({ children }) { const router = useRouter(); const { t } = useTranslation("peppermint"); + const { user } = useUser(); const linkStyles = { active: @@ -15,7 +15,6 @@ export default function Settings({ children }) { inactive: "w-full border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group mt-1 border-l-4 px-3 py-2 flex items-center text-sm font-medium", }; - const [show, setShow] = useState("profile"); return (
@@ -78,31 +77,33 @@ export default function Settings({ children }) { {t("notifications")} - - - {t("reset_password")} - + + {t("reset_password")} + + )} diff --git a/apps/client/lib/nodemailer/ticket/create.js b/apps/client/lib/nodemailer/ticket/create.js deleted file mode 100644 index e24273182..000000000 --- a/apps/client/lib/nodemailer/ticket/create.js +++ /dev/null @@ -1,97 +0,0 @@ -import nodeMailer from "nodemailer"; -import { prisma } from "../../../prisma/prisma"; - -export async function sendTicketCreate(ticket) { - try { - let mail; - - const emails = await prisma.email.findMany(); - - if (emails.length > 0) { - if (process.env.NODE_ENV === "development") { - let testAccount = await nodeMailer.createTestAccount(); - mail = nodeMailer.createTransport({ - port: 1025, - secure: false, // true for 465, false for other ports - auth: { - user: testAccount.user, // generated ethereal user - pass: testAccount.pass, // generated ethereal password - }, - }); - } else { - const email = emails[0]; - mail = nodeMailer.createTransport({ - host: email.host, - port: email.port, - secure: email.secure, // true for 465, false for other ports - auth: { - user: email.user, // generated ethereal user - pass: email.pass, // generated ethereal password - }, - }); - } - - let info = await mail.sendMail({ - from: '"No reply 👻" noreply@peppermint.sh', // sender address - to: ticket.email, - subject: `Ticket ${ticket.id} has just been created & logged`, // Subject line - text: `Hello there, Ticket ${ticket.id}, which you reported on ${ticket.createdAt}, has now been created and logged`, // plain text body - html: ` - - - - - - -
Ticket Created
-
- - -
- - - -
- - - - - - -
Slack
-

Ticket Created: ${ticket.id}

-

-

Hello,
Your ticket has now been created and logged.

-

- Kind regards, -
- Peppermint ticket management -

- - - - - - - -
- Our blog | Documentation | Discord -

This was an automated message sent by peppermint.sh -> An open source helpdesk solution

-

©2022 Peppermint Ticket Management, a Peppermint Labs product.
All rights reserved.

-
-
- - - - `, - }); - - console.log("Message sent: %s", info.messageId); - - // Preview only available when sending through an Ethereal account - console.log("Preview URL: %s", nodeMailer.getTestMessageUrl(info)); - } - } catch (error) { - console.log(error); - } -} diff --git a/apps/client/lib/nodemailer/ticket/status.js b/apps/client/lib/nodemailer/ticket/status.js deleted file mode 100644 index 064d07a8e..000000000 --- a/apps/client/lib/nodemailer/ticket/status.js +++ /dev/null @@ -1,102 +0,0 @@ -import nodeMailer from "nodemailer"; -import { prisma } from "../../../prisma/prisma"; - -export async function sendTicketStatus(ticket) { - let mail; - - const emails = await prisma.email.findMany(); - - if (emails.length > 0) { - if (process.env.NODE_ENV === "development") { - let testAccount = await nodeMailer.createTestAccount(); - mail = nodeMailer.createTransport({ - port: 1025, - secure: false, // true for 465, false for other ports - auth: { - user: testAccount.user, // generated ethereal user - pass: testAccount.pass, // generated ethereal password - }, - }); - } else { - const email = emails[0]; - mail = nodeMailer.createTransport({ - host: email.host, - port: email.port, - secure: email.secure, // true for 465, false for other ports - auth: { - user: email.user, // generated ethereal user - pass: email.pass, // generated ethereal password - }, - }); - } - - let info = await mail.sendMail({ - from: "", // sender address - to: ticket.email, - subject: `Ticket ${ticket.id} status is now ${ - ticket.isComplete ? "COMPLETED" : "OUTSTANDING" - }`, // Subject line - text: `Hello there, Ticket ${ticket.id}, now has a status of ${ - ticket.isComplete ? "COMPLETED" : "OUTSTANDING" - }`, // plain text body - html: ` - - - - - - -
Ticket Created
-
- - - - - - -
- - - - - - -
Slack
-

Ticket Created: ${ - ticket.title - }

-

-

Hello there,
Your Ticket, which was #${ - ticket.number - }, now has a status of ${ - ticket.isComplete ? "completed" : "open" - }

- Kind regards, -
- Peppermint ticket management -

- - - - - - - -
- Our blog | Documentation | Discord -

This was an automated message sent by peppermint.sh -> An open source helpdesk solution

-

©2022 Peppermint Ticket Management, a Peppermint Labs product.
All rights reserved.

-
-
- - - - `, - }); - - console.log("Message sent: %s", info.messageId); - - // Preview only available when sending through an Ethereal account - console.log("Preview URL: %s", nodeMailer.getTestMessageUrl(info)); - } -} diff --git a/apps/client/locales/da/peppermint.json b/apps/client/locales/da/peppermint.json index d08aeaee9..c72385655 100644 --- a/apps/client/locales/da/peppermint.json +++ b/apps/client/locales/da/peppermint.json @@ -78,7 +78,11 @@ "profile_desc": "Disse oplysninger vil blive vist offentligt, så vær forsigtig med, hvad du deler.", "language": "Sprog", "notifications": "Notifikationer", - "save_and_reload": "Gem og genindlæs" + "save_and_reload": "Gem og genindlæs", + "select_a_client": "Vælg en kunde", + "select_an_engineer": "Vælg en ingeniør", + "ticket_create": "Opret billet", + "internallycommented_at": "Kommenteret internt kl." } \ No newline at end of file diff --git a/apps/client/locales/de/peppermint.json b/apps/client/locales/de/peppermint.json index 908263930..e998b1d08 100644 --- a/apps/client/locales/de/peppermint.json +++ b/apps/client/locales/de/peppermint.json @@ -78,5 +78,9 @@ "profile_desc": "Diese Informationen werden öffentlich angezeigt, also achten Sie darauf, was Sie teilen.", "language": "Sprache", "notifications": "Benachrichtigungen", - "save_and_reload": "Speichern und neu laden" + "save_and_reload": "Speichern und neu laden", + "select_a_client": "Kunden auswählen", + "select_an_engineer": "Einen Ingenieur auswählen", + "ticket_create": "Ticket erstellen", + "internallycommented_at": "Intern kommentiert um" } diff --git a/apps/client/locales/en/peppermint.json b/apps/client/locales/en/peppermint.json index 594d220e6..53c47c66f 100644 --- a/apps/client/locales/en/peppermint.json +++ b/apps/client/locales/en/peppermint.json @@ -78,5 +78,9 @@ "profile_desc": "This information will be displayed publicly so be careful what you share.", "language": "Language", "notifications": "Notifications", - "save_and_reload": "Save and Reload" + "save_and_reload": "Save and Reload", + "select_a_client": "Select a client", + "select_an_engineer": "Select an engineer", + "ticket_create": "Create Ticket", + "internallycommented_at": "Internally Commented At" } diff --git a/apps/client/locales/es/peppermint.json b/apps/client/locales/es/peppermint.json index 5a1b18f4c..6978c7393 100644 --- a/apps/client/locales/es/peppermint.json +++ b/apps/client/locales/es/peppermint.json @@ -78,6 +78,10 @@ "profile_desc": "Esta información se mostrará públicamente, así que ten cuidado con lo que compartes.", "language": "Idioma", "notifications": "Notificaciones", - "save_and_reload": "Guardar y recargar" + "save_and_reload": "Guardar y recargar", + "select_a_client": "Seleccionar un cliente", + "select_an_engineer": "Seleccionar un ingeniero", + "ticket_create": "Crear ticket", + "internallycommented_at": "Comentado internamente en" } \ No newline at end of file diff --git a/apps/client/locales/fr/peppermint.json b/apps/client/locales/fr/peppermint.json index 0207fe08d..89357cb5a 100644 --- a/apps/client/locales/fr/peppermint.json +++ b/apps/client/locales/fr/peppermint.json @@ -78,7 +78,11 @@ "profile_desc": "Ces informations seront affichées publiquement, alors faites attention à ce que vous partagez.", "language": "Langue", "notifications": "Notifications", - "save_and_reload": "Enregistrer et recharger" + "save_and_reload": "Enregistrer et recharger", + "select_a_client": "Sélectionner un client", + "select_an_engineer": "Sélectionner un ingénieur", + "ticket_create": "Créer un ticket", + "internallycommented_at": "Commenté en interne à" } \ No newline at end of file diff --git a/apps/client/locales/no/peppermint.json b/apps/client/locales/no/peppermint.json index 611f149c6..d9c823d56 100644 --- a/apps/client/locales/no/peppermint.json +++ b/apps/client/locales/no/peppermint.json @@ -78,6 +78,10 @@ "profile_desc": "Denne informasjonen vil bli vist offentlig, så vær forsiktig med hva du deler.", "language": "Språk", "notifications": "Varsler", - "save_and_reload": "Lagre og last inn på nytt" + "save_and_reload": "Lagre og last inn på nytt", + "select_a_client": "Velg en kunde", + "select_an_engineer": "Velg en ingeniør", + "ticket_create": "Opprett billett", + "internallycommented_at": "Kommentert internt kl." } \ No newline at end of file diff --git a/apps/client/locales/pt/peppermint.json b/apps/client/locales/pt/peppermint.json index 20bd655d4..f26e2f3a1 100644 --- a/apps/client/locales/pt/peppermint.json +++ b/apps/client/locales/pt/peppermint.json @@ -78,6 +78,10 @@ "profile_desc": "Estas informações serão exibidas publicamente, então tenha cuidado com o que compartilha.", "language": "Idioma", "notifications": "Notificações", - "save_and_reload": "Salvar e Recarregar" + "save_and_reload": "Salvar e Recarregar", + "select_a_client": "Selecionar um cliente", + "select_an_engineer": "Selecionar um engenheiro", + "ticket_create": "Criar ticket", + "internallycommented_at": "Comentado internamente às" } \ No newline at end of file diff --git a/apps/client/locales/se/peppermint.json b/apps/client/locales/se/peppermint.json index 1306e0635..107e1343b 100644 --- a/apps/client/locales/se/peppermint.json +++ b/apps/client/locales/se/peppermint.json @@ -78,5 +78,9 @@ "profile_desc": "Denna information kommer att visas offentligt, så var försiktig med vad du delar.", "language": "Språk", "notifications": "Notifieringar", - "save_and_reload": "Spara och ladda om" + "save_and_reload": "Spara och ladda om", + "select_a_client": "Välj en klient", + "select_an_engineer": "Välj en ingenjör", + "ticket_create": "Skapa en biljett", + "internallycommented_at": "Kommenterat internt kl." } diff --git a/apps/client/locales/tl/peppermint.json b/apps/client/locales/tl/peppermint.json index fee9879b7..9345f950e 100644 --- a/apps/client/locales/tl/peppermint.json +++ b/apps/client/locales/tl/peppermint.json @@ -78,7 +78,11 @@ "profile_desc": "Ang impormasyong ito ay ipapakita sa publiko kaya mag-ingat sa iyong ibabahagi.", "language": "Wika", "notifications": "Mga Abiso", - "save_and_reload": "I-save at I-reload" + "save_and_reload": "I-save at I-reload", + "select_a_client": "Pumili ng isang kliyente", + "select_an_engineer": "Pumili ng isang inhinyero", + "ticket_create": "Gumawa ng tiket", + "internallycommented_at": "Internallyo nag-komento sa" } \ No newline at end of file diff --git a/apps/client/package.json b/apps/client/package.json index 3817591b0..cfc2acf00 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,6 +16,7 @@ "@mantine/notifications": "^6.0.13", "@mantine/spotlight": "^6.0.13", "@mantine/tiptap": "^6.0.13", + "@radix-ui/themes": "^2.0.1", "@tabler/icons-react": "^2.20.0", "@tailwindcss/forms": "^0.4.0", "@tiptap/extension-highlight": "^2.0.3", diff --git a/apps/client/pages/_app.tsx b/apps/client/pages/_app.tsx index 936d9a267..15b48ff63 100644 --- a/apps/client/pages/_app.tsx +++ b/apps/client/pages/_app.tsx @@ -1,3 +1,4 @@ +import "@radix-ui/themes/styles.css"; import "../styles/globals.css"; import { @@ -9,6 +10,7 @@ import { import { MantineProvider } from "@mantine/core"; import { Notifications } from "@mantine/notifications"; import { SpotlightProvider } from "@mantine/spotlight"; +import { Theme } from "@radix-ui/themes"; import { useRouter } from "next/router"; import { QueryClient, QueryClientProvider } from "react-query"; @@ -37,10 +39,7 @@ function Auth({ children }: any) { } return ( -
- {/* */} - loading -
+
); } @@ -92,32 +91,35 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: any) { if (router.asPath.slice(0, 5) === "/auth") { return ( - + <> + - + ); } if (router.pathname.includes("/admin")) { return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); } @@ -192,22 +194,24 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: any) { return ( - - - - - - - - - - - - + + + + + + + + + + + + + + ); } diff --git a/apps/client/pages/admin/email-queues/new.js b/apps/client/pages/admin/email-queues/new.js index 3b9b92a4b..5f78b24e0 100644 --- a/apps/client/pages/admin/email-queues/new.js +++ b/apps/client/pages/admin/email-queues/new.js @@ -108,6 +108,7 @@ export default function EmailQueues() { onChange={(e) => setHostname(e.target.value)} />
+