diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 1dd79ad..f06dab1 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -1,7 +1,14 @@ +BASE_URL = APP_PORT = 3000 DATABASE_URL = ACCESS_TOKEN_SECRET = REFRESH_TOKEN_SECRET = -COOKIE_SECRET = \ No newline at end of file +RESET_PASSWORD_TOKEN_SECRET = +COOKIE_SECRET = + +SMTP_USERNAME = +SMTP_PASSWORD = +SMTP_HOST = +SMTP_PORT = \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index a923b14..21a8d3b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -19,6 +19,7 @@ "hono": "^4.4.6", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", + "nodemailer": "^6.9.14", "postgres": "^3.4.4", "sharp": "^0.33.4", "zod": "^3.23.8" @@ -27,6 +28,7 @@ "@types/bcrypt": "^5.0.2", "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20.14.2", + "@types/nodemailer": "^6.4.15", "drizzle-kit": "^0.22.7", "pg": "^8.12.0", "tsx": "^4.15.5", diff --git a/apps/backend/src/appEnv.ts b/apps/backend/src/appEnv.ts index bedf2a1..9d4672a 100644 --- a/apps/backend/src/appEnv.ts +++ b/apps/backend/src/appEnv.ts @@ -4,11 +4,17 @@ import { z } from "zod"; dotenv.config(); const envSchema = z.object({ + BASE_URL: z.string(), APP_PORT: z.coerce.number().int(), DATABASE_URL: z.string(), ACCESS_TOKEN_SECRET: z.string(), REFRESH_TOKEN_SECRET: z.string(), + RESET_PASSWORD_TOKEN_SECRET: z.string(), COOKIE_SECRET: z.string(), + SMTP_USERNAME: z.string(), + SMTP_PASSWORD: z.string(), + SMTP_HOST: z.string(), + SMTP_PORT: z.coerce.number().int(), }); const parsedEnv = envSchema.safeParse(process.env); diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index e4d458c..d0a7c76 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -47,6 +47,63 @@ const permissionsData = [ { code: "questions.restore", }, + { + code: "managementAspect.readAll", + }, + { + code: "managementAspect.create", + }, + { + code: "managementAspect.update", + }, + { + code: "managementAspect.delete", + }, + { + code: "managementAspect.restore", + }, + { + code: "assessmentResult.readAll", + }, + { + code: "assessmentResult.read", + }, + { + code: "assessmentResult.readAllQuestions", + }, + { + code: "assessmentResult.create", + }, + { + code: "assessmentRequest.read", + }, + { + code: "assessmentRequest.create", + }, + { + code: "assessments.readAssessmentScore", + }, + { + code: "assessments.readAllQuestions", + }, + { + code: "assessments.readAnswers", + }, + { + code: "assessments.toggleFlag", + }, + { + code: "assessments.checkAnswer", + }, + { + code: "assessments.uploadFile", + }, + { + code: "assessments.submitAnswer", + }, + { + code: "assessments.updateAnswer", + }, ] as const; export type SpecificPermissionCode = (typeof permissionsData)[number]["code"]; diff --git a/apps/backend/src/drizzle/schema/assessments.ts b/apps/backend/src/drizzle/schema/assessments.ts index 44eb4ed..75b9ccd 100644 --- a/apps/backend/src/drizzle/schema/assessments.ts +++ b/apps/backend/src/drizzle/schema/assessments.ts @@ -4,7 +4,7 @@ import { relations } from "drizzle-orm"; import { respondents } from "./respondents"; import { users } from "./users"; -const statusEnum = pgEnum("status", ["tertunda", "disetujui", "ditolak", "selesai"]); +const statusEnum = pgEnum("status", ["menunggu konfirmasi", "disetujui", "ditolak", "selesai"]); export const assessments = pgTable("assessments", { id: varchar("id", { length: 50 }) @@ -14,10 +14,9 @@ export const assessments = pgTable("assessments", { status: statusEnum("status"), reviewedBy: varchar("reviewedBy"), reviewedAt: timestamp("reviewedAt", { mode: "date" }), - validatedBy: varchar("validatedBy").notNull(), + validatedBy: varchar("validatedBy"), validatedAt: timestamp("validatedAt", { mode: "date" }), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), - }); // Query Tools in PosgreSQL -// CREATE TYPE status AS ENUM ('tertunda', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file +// CREATE TYPE status AS ENUM ('menunggu konfirmasi', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file diff --git a/apps/backend/src/drizzle/schema/users.ts b/apps/backend/src/drizzle/schema/users.ts index 0574af0..ff5fa03 100644 --- a/apps/backend/src/drizzle/schema/users.ts +++ b/apps/backend/src/drizzle/schema/users.ts @@ -20,6 +20,7 @@ export const users = pgTable("users", { email: varchar("email"), password: text("password").notNull(), isEnabled: boolean("isEnabled").default(true), + resetPasswordToken: varchar("resetPasswordToken"), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(), deletedAt: timestamp("deletedAt", { mode: "date" }), diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 704b19a..7133f40 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -3,6 +3,7 @@ import { configDotenv } from "dotenv"; import { Hono } from "hono"; import authRoutes from "./routes/auth/route"; import usersRoute from "./routes/users/route"; +import managementAspectsRoute from "./routes/managementAspect/route"; import respondentsRoute from "./routes/register/route"; import { verifyAccessToken } from "./utils/authUtils"; import permissionRoutes from "./routes/permissions/route"; @@ -17,6 +18,10 @@ import HonoEnv from "./types/HonoEnv"; import devRoutes from "./routes/dev/route"; import appEnv from "./appEnv"; import questionsRoute from "./routes/questions/route"; +import assessmentResultRoute from "./routes/assessmentResult/route"; +import assessmentRequestRoute from "./routes/assessmentRequest/route"; +import forgotPasswordRoutes from "./routes/forgotPassword/route"; +import assessmentsRoute from "./routes/assessments/route"; configDotenv(); @@ -81,7 +86,12 @@ const routes = app .route("/roles", rolesRoute) .route("/dev", devRoutes) .route("/questions", questionsRoute) + .route("/management-aspect", managementAspectsRoute) .route("/register", respondentsRoute) + .route("/assessmentResult", assessmentResultRoute) + .route("/assessmentRequest", assessmentRequestRoute) + .route("/forgot-password", forgotPasswordRoutes) + .route("/assessments", assessmentsRoute) .onError((err, c) => { if (err instanceof DashboardError) { return c.json( diff --git a/apps/backend/src/routes/assessmentRequest/route.ts b/apps/backend/src/routes/assessmentRequest/route.ts new file mode 100644 index 0000000..3f189a9 --- /dev/null +++ b/apps/backend/src/routes/assessmentRequest/route.ts @@ -0,0 +1,105 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import db from "../../drizzle"; +import { respondents } from "../../drizzle/schema/respondents"; +import { assessments } from "../../drizzle/schema/assessments"; +import { users } from "../../drizzle/schema/users"; +import { rolesToUsers } from "../../drizzle/schema/rolesToUsers"; +import { rolesSchema } from "../../drizzle/schema/roles"; +import HonoEnv from "../../types/HonoEnv"; +import authInfo from "../../middlewares/authInfo"; +import { notFound } from "../../errors/DashboardError"; +import checkPermission from "../../middlewares/checkPermission"; +import requestValidator from "../../utils/requestValidator"; +import { HTTPException } from "hono/http-exception"; +import { createId } from "@paralleldrive/cuid2"; + +const assessmentRequestRoute = new Hono() + .use(authInfo) + + // Get assessment request by user ID + .get( + "/:id", + checkPermission("assessmentRequest.read"), + requestValidator( + "query", + z.object({ + includeTrashed: z.string().default("false"), + }) + ), + async (c) => { + const userId = c.req.param("id"); + + const queryResult = await db + .select({ + userId: users.id, + createdAt: assessments.createdAt, + name: users.name, + code: rolesSchema.code, + status: assessments.status, + }) + .from(users) + .leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId)) + .leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id)) + .leftJoin(respondents, eq(users.id, respondents.userId)) + .leftJoin(assessments, eq(respondents.id, assessments.respondentId)) + .where(eq(users.id, userId)); + + if (!queryResult[0]) throw notFound(); + + const assessmentRequestData = { + ...queryResult, + }; + + return c.json(assessmentRequestData); + } + ) + + // Post assessment request by user ID + .post( + "/:id", + checkPermission("assessmentRequest.create"), + requestValidator( + "json", + z.object({ + respondentId: z.string().min(1), + }) + ), + async (c) => { + const { respondentId } = c.req.valid("json"); + const userId = c.req.param("id"); + + // Make sure the userId exists + if (!userId) { + throw new HTTPException(400, { message: "User ID is required." }); + } + + // Validate if respondent exists + const respondent = await db + .select() + .from(respondents) + .where(eq(respondents.id, respondentId)); + + if (!respondent.length) { + throw new HTTPException(404, { message: "Respondent not found." }); + } + + // Create the assessment request + const newAssessment = await db + .insert(assessments) + .values({ + id: createId(), + respondentId, + status: "menunggu konfirmasi", + validatedBy: null, + validatedAt: null, + createdAt: new Date(), + }) + .returning(); + + return c.json({ message: "Successfully submitted the assessment request" }, 201); + } + ); + +export default assessmentRequestRoute; \ No newline at end of file diff --git a/apps/backend/src/routes/assessmentResult/route.ts b/apps/backend/src/routes/assessmentResult/route.ts new file mode 100644 index 0000000..5b9e989 --- /dev/null +++ b/apps/backend/src/routes/assessmentResult/route.ts @@ -0,0 +1,238 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import db from "../../drizzle"; +import { assessments } from "../../drizzle/schema/assessments"; +import { respondents } from "../../drizzle/schema/respondents"; +import { users } from "../../drizzle/schema/users"; +import { aspects } from "../../drizzle/schema/aspects"; +import { subAspects } from "../../drizzle/schema/subAspects"; +import { questions } from "../../drizzle/schema/questions"; +import { options } from "../../drizzle/schema/options"; +import { answers } from "../../drizzle/schema/answers"; +import { answerRevisions } from "../../drizzle/schema/answerRevisions"; +import HonoEnv from "../../types/HonoEnv"; +import authInfo from "../../middlewares/authInfo"; +import checkPermission from "../../middlewares/checkPermission"; +import requestValidator from "../../utils/requestValidator"; +import { notFound } from "../../errors/DashboardError"; + +const assessmentRoute = new Hono() + .use(authInfo) + + // Get All List of Assessment Results + .get( + "/", + checkPermission("assessmentResult.readAll"), + requestValidator( + "query", + z.object({ + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(40), + }) + ), + async (c) => { + const { page, limit } = c.req.valid("query"); + + const result = await db + .select({ + id: assessments.id, + respondentName: users.name, + companyName: respondents.companyName, + statusAssessments: assessments.status, + statusVerification: sql` + CASE + WHEN ${assessments.validatedAt} IS NOT NULL THEN 'sudah diverifikasi' + ELSE 'belum diverifikasi' + END` + .as("statusVerification"), + assessmentsResult: sql` + (SELECT ROUND(AVG(${options.score}), 2) + FROM ${answers} + JOIN ${options} ON ${options.id} = ${answers.optionId} + JOIN ${questions} ON ${questions.id} = ${options.questionId} + JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId} + JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId} + WHERE ${answers.assessmentId} = ${assessments.id})` + .as("assessmentsResult"), + }) + .from(assessments) + .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) + .leftJoin(users, eq(respondents.userId, users.id)) + .offset(page * limit) + .limit(limit); + + const totalItems = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(assessments); + + return c.json({ + data: result, + _metadata: { + currentPage: page, + totalPages: Math.ceil(totalItems[0].count / limit), + totalItems: totalItems[0].count, + perPage: limit, + }, + }); + } + ) + + // Get Assessment Result by ID + .get( + "/:id", + checkPermission("assessmentResult.read"), + async (c) => { + const assessmentId = c.req.param("id"); + + const result = await db + .select({ + respondentName: users.name, + position: respondents.position, + workExperience: respondents.workExperience, + email: users.email, + companyName: respondents.companyName, + address: respondents.address, + phoneNumber: respondents.phoneNumber, + username: users.username, + assessmentDate: assessments.createdAt, + statusAssessment: assessments.status, + assessmentsResult: sql` + (SELECT ROUND(AVG(${options.score}), 2) + FROM ${answers} + JOIN ${options} ON ${options.id} = ${answers.optionId} + JOIN ${questions} ON ${questions.id} = ${options.questionId} + JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId} + JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId} + WHERE ${answers.assessmentId} = ${assessments.id})` + .as("assessmentsResult"), + }) + .from(assessments) + .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) + .leftJoin(users, eq(respondents.userId, users.id)) + .where(eq(assessments.id, assessmentId)); + + if (!result.length) { + throw notFound({ + message: "Assessment not found", + }); + } + + return c.json(result[0]); + } + ) + + // Get all Questions and Options that relate to Sub Aspects and Aspects based on Assessment ID + .get( + "getAllQuestion/:id", + checkPermission("assessmentResult.readAllQuestions"), + async (c) => { + const assessmentId = c.req.param("id"); + + if (!assessmentId) { + throw notFound({ + message: "Assessment ID is missing", + }); + } + + // Total count of options related to the assessment + const totalCountQuery = sql` + SELECT count(*) + FROM ${options} + LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id} + LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id} + LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id} + LEFT JOIN ${answers} ON ${options.id} = ${answers.optionId} + WHERE ${questions.deletedAt} IS NULL + AND ${answers.assessmentId} = ${assessmentId} + `; + + // Query to get detailed information about options + const result = await db + .select({ + optionId: options.id, + aspectsId: aspects.id, + aspectsName: aspects.name, + subAspectId: subAspects.id, + subAspectName: subAspects.name, + questionId: questions.id, + questionText: questions.question, + answerId: answers.id, + answerText: options.text, + answerScore: options.score, + }) + .from(options) + .leftJoin(questions, eq(options.questionId, questions.id)) + .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .leftJoin(answers, eq(options.id, answers.optionId)) + .where(sql`${questions.deletedAt} IS NULL AND ${answers.assessmentId} = ${assessmentId}`); + + // Execute the total count query + const totalCountResult = await db.execute(totalCountQuery); + + if (result.length === 0) { + throw notFound({ + message: "Data does not exist", + }); + } + + return c.json({ + data: result, + totalCount: totalCountResult[0]?.count || 0 + }); + } + ) + + // POST Endpoint for creating a new answer revision + .post( + "/answer-revisions", + checkPermission("assessmentResult.create"), + requestValidator( + "json", + z.object({ + answerId: z.string(), + newOptionId: z.string(), + revisedBy: z.string(), + newValidationInformation: z.string(), + }) + ), + async (c) => { + const { answerId, newOptionId, revisedBy, newValidationInformation } = c.req.valid("json"); + + // Check if the answer exists + const existingAnswer = await db + .select() + .from(answers) + .where(eq(answers.id, answerId)); + + if (!existingAnswer.length) { + throw notFound({ + message: "Answer not found", + }); + } + + // Insert new revision + const [newRevision] = await db + .insert(answerRevisions) + .values({ + answerId, + newOptionId, + revisedBy, + newValidationInformation + }) + .returning(); + + return c.json( + { + message: "Answer revision created successfully", + data: newRevision + }, + 201 + ); + } + ); + +export default assessmentRoute; diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts new file mode 100644 index 0000000..604c903 --- /dev/null +++ b/apps/backend/src/routes/assessments/route.ts @@ -0,0 +1,536 @@ +import { and, eq, ilike, or, sql } from "drizzle-orm"; +import { Hono } from "hono"; +import { z } from "zod"; +import db from "../../drizzle"; +import { answers } from "../../drizzle/schema/answers"; +import { options } from "../../drizzle/schema/options"; +import { questions } from "../../drizzle/schema/questions"; +import { subAspects } from "../../drizzle/schema/subAspects"; +import { aspects } from "../../drizzle/schema/aspects"; +import { assessments } from "../../drizzle/schema/assessments"; +import HonoEnv from "../../types/HonoEnv"; +import requestValidator from "../../utils/requestValidator"; +import authInfo from "../../middlewares/authInfo"; +import checkPermission from "../../middlewares/checkPermission"; +import path from "path"; +import fs from 'fs'; +import { notFound } from "../../errors/DashboardError"; + +export const answerFormSchema = z.object({ + optionId: z.string().min(1), + assessmentId: z.string().min(1), + isFlagged: z.boolean().optional().default(false), + filename: z.string().optional(), + validationInformation: z.string().min(1), +}); + +export const answerUpdateSchema = answerFormSchema.partial(); + +// Helper function to save the file +async function saveFile(filePath: string, fileBuffer: Buffer): Promise { + await fs.promises.writeFile(filePath, fileBuffer); +} + +// Function to update the filename in the database +async function updateFilenameInDatabase(answerId: string, filename: string): Promise { + + await db.update(answers) + .set({ filename }) + .where(eq(answers.id, answerId)); +} + +const assessmentsRoute = new Hono() + .use(authInfo) + + // Get data for current Assessment Score from submitted options By Assessment Id + .get( + "/getCurrentAssessmentScore", + checkPermission("assessments.readAssessmentScore"), + requestValidator( + "query", + z.object({ + assessmentId: z.string(), + }) + ), + async (c) => { + const { assessmentId } = c.req.valid("query"); + + // Query to sum the scores of selected options for the current assessment + const result = await db + .select({ + totalScore: sql`SUM(${options.score})`, + }) + .from(answers) + .leftJoin(options, eq(answers.optionId, options.id)) + .where(eq(answers.assessmentId, assessmentId)) + .execute(); + + return c.json({ + assessmentId, + totalScore: result[0]?.totalScore ?? 0, // Return 0 if no answers are found + }); + } + ) + + // Get all Questions and Options that relate to Sub Aspects and Aspects + .get( + "/getAllQuestions", + checkPermission("assessments.readAllQuestions"), + async (c) => { + const totalCountQuery = + sql`(SELECT count(*) + FROM ${options} + LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id} + LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id} + LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id} + WHERE ${questions.deletedAt} IS NULL + )`; + + const result = await db + .select({ + optionId: options.id, + aspectsId: aspects.id, + aspectsName: aspects.name, + subAspectId: subAspects.id, + subAspectName: subAspects.name, + questionId: questions.id, + questionText: questions.question, + optionText: options.text, + optionScore: options.score, + fullCount: totalCountQuery, + }) + .from(options) + .leftJoin(questions, eq(options.questionId, questions.id)) + .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .where(sql`${questions.deletedAt} IS NULL`) + + return c.json({ + data: result.map((d) => ( + { + ...d, + fullCount: undefined + } + )), + }); + } + ) + + // Get all Answers Data by Assessment Id + .get( + "/getAnswers", + checkPermission("assessments.readAnswers"), + requestValidator( + "query", + z.object({ + assessmentId: z.string(), // Require assessmentId as a query parameter + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(1000), + q: z.string().default(""), + }) + ), + async (c) => { + const { assessmentId, page, limit, q } = c.req.valid("query"); + + // Query to count total answers for the specific assessmentId + const totalCountQuery = + sql`(SELECT count(*) + FROM ${answers} + WHERE ${answers.assessmentId} = ${assessmentId})`; + + // Query to retrieve answers for the specific assessmentId + const result = await db + .select({ + id: answers.id, + assessmentId: answers.assessmentId, + optionId: answers.optionId, + isFlagged: answers.isFlagged, + filename: answers.filename, + validationInformation: answers.validationInformation, + fullCount: totalCountQuery, + }) + .from(answers) + .where( + and( + eq(answers.assessmentId, assessmentId), // Filter by assessmentId + q + ? or( + ilike(answers.filename, q), + ilike(answers.validationInformation, q), + eq(answers.id, q) + ) + : undefined + ) + ) + .offset(page * limit) + .limit(limit); + + return c.json({ + data: result.map((d) => ({ ...d, fullCount: undefined })), + _metadata: { + currentPage: page, + totalPages: Math.ceil( + (Number(result[0]?.fullCount) ?? 0) / limit + ), + totalItems: Number(result[0]?.fullCount) ?? 0, + perPage: limit, + }, + }); + } + ) + + // Toggles the isFlagged field between true and false + .patch( + "/:id/toggleFlag", + checkPermission("assessments.toggleFlag"), + async (c) => { + const answerId = c.req.param("id"); + + // Retrieve the current state of isFlagged + const currentAnswer = await db + .select({ + isFlagged: answers.isFlagged, + }) + .from(answers) + .where(eq(answers.id, answerId)) + .limit(1); + + if (!currentAnswer.length) { + throw notFound( + { + message: "Answer not found", + } + ) + } + + // Toggle the isFlagged value + const newIsFlaggedValue = !currentAnswer[0].isFlagged; + + // Update the answer with the toggled value + const updatedAnswer = await db + .update(answers) + .set({ + isFlagged: newIsFlaggedValue, + }) + .where(eq(answers.id, answerId)) + .returning(); + + if (!updatedAnswer.length) { + throw notFound( + { + message: "Failed to update answer", + } + ) + } + + return c.json( + { + message: "Answer flag toggled successfully", + answer: updatedAnswer[0], + }, + 200 + ); + } + ) + + // Get data answers from table answers by optionId and assessmentId + .post( + "/checkDataAnswer", + checkPermission("assessments.checkAnswer"), + async (c) => { + const { optionId, assessmentId } = await c.req.json(); + + const result = await db + .select() + .from(answers) + .where( + and(eq(answers.optionId, optionId), eq(answers.assessmentId, assessmentId)) + ) + .execute(); + + const existingAnswer = result[0]; + let response; + + if (existingAnswer) { + response = { + exists: true, + answerId: existingAnswer.id + }; + } else { + response = { + exists: false + }; + } + + return c.json(response); + } + ) + + // Upload filename to the table answers and save the file on the local storage + .post( + "/uploadFile", + checkPermission("assessments.uploadFile"), + async (c) => { + // Get the Content-Type header + const contentType = c.req.header('content-type'); + if (!contentType || !contentType.includes('multipart/form-data')) { + throw notFound({ + message: "Invalid Content-Type", + }); + } + + // Extract boundary + const boundary = contentType.split('boundary=')[1]; + if (!boundary) { + throw notFound({ + message: "Boundary not found", + }); + } + + // Get the raw body + const body = await c.req.arrayBuffer(); + const bodyString = Buffer.from(body).toString(); + + // Split the body by the boundary + const parts = bodyString.split(`--${boundary}`); + + let fileUrl = null; + + for (const part of parts) { + if (part.includes('Content-Disposition: form-data;')) { + // Extract file name + const match = /filename="(.+?)"/.exec(part); + if (match) { + const fileName = match[1]; + const fileContentStart = part.indexOf('\r\n\r\n') + 4; + const fileContentEnd = part.lastIndexOf('\r\n'); + + // Extract file content as Buffer + const fileBuffer = Buffer.from(part.slice(fileContentStart, fileContentEnd), 'binary'); + + // Define file path and save the file + const filePath = path.join('images', Date.now() + '-' + fileName); + await saveFile(filePath, fileBuffer); + + // Assuming answerId is passed as a query parameter or in the form-data + const answerId = c.req.query('answerId'); + if (!answerId) { + throw notFound({ + message: "answerId is required", + }); + } + + await updateFilenameInDatabase(answerId, path.basename(filePath)); + + // Set the file URL for the final response + fileUrl = `/images/${path.basename(filePath)}`; + } + } + } + + if (!fileUrl) { + throw notFound({ + message: 'No file uploaded', + }); + } + + return c.json( + { + success: true, + imageUrl: fileUrl + } + ); + } + ) + + // Submit option to table answers from use-form in frontend + .post( + "/submitAnswer", + checkPermission("assessments.submitAnswer"), + requestValidator("json", answerFormSchema), + async (c) => { + const answerData = c.req.valid("json"); + + const answer = await db + .insert(answers) + .values({ + optionId: answerData.optionId, + assessmentId: answerData.assessmentId, + isFlagged: answerData.isFlagged, + filename: answerData.filename, + validationInformation: answerData.validationInformation, + }) + .returning(); + + return c.json( + { + message: "Answer created successfully", + answer: answer[0], + }, + 201 + ); + } + ) + + // Update answer in table answers if answer changes + .patch( + "/:id/updateAnswer", + checkPermission("assessments.updateAnswer"), + requestValidator("json", answerUpdateSchema), + async (c) => { + const answerId = c.req.param("id"); + const answerData = c.req.valid("json"); + + const updatedAnswer = await db + .update(answers) + .set({ + optionId: answerData.optionId, + }) + .where(eq(answers.id, answerId)) + .returning(); + + if (!updatedAnswer.length) { + throw notFound({ + message: "Answer not found or update failed" + }) + } + + return c.json({ + message: "Answer updated successfully", + answer: updatedAnswer[0], + }); + } + ) + + // Get data for One Sub Aspect average score By Sub Aspect Id and Assessment Id + .get( + '/average-score/sub-aspects/:subAspectId/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), + async (c) => { + const { subAspectId, assessmentId } = c.req.param(); + + const averageScore = await db + .select({ + subAspectName: subAspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where( + sql`sub_aspects.id = ${subAspectId} AND assessments.id = ${assessmentId}` + ) + .groupBy(subAspects.id); + + return c.json({ + subAspectId, + subAspectName: averageScore[0].subAspectName, + assessmentId, + averageScore: averageScore.length > 0 ? averageScore[0].average : 0 + }); + } + ) + + // Get data for All Sub Aspects average score By Assessment Id + .get( + '/average-score/sub-aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), + async (c) => { + const { assessmentId } = c.req.param(); + + const averageScores = await db + .select({ + subAspectId: subAspects.id, + subAspectName: subAspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where(eq(assessments.id, assessmentId)) + .groupBy(subAspects.id); + + return c.json({ + assessmentId, + subAspects: averageScores.map(score => ({ + subAspectId: score.subAspectId, + subAspectName: score.subAspectName, + averageScore: score.average + })) + }); + } + ) + + // Get data for One Aspect average score By Aspect Id and Assessment Id + .get( + "/average-score/aspects/:aspectId/assessments/:assessmentId", + async (c) => { + const { aspectId, assessmentId } = c.req.param(); + + const averageScore = await db + .select({ + aspectName: aspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where( + sql`aspects.id = ${aspectId} AND assessments.id = ${assessmentId}` + ) + .groupBy(aspects.id); + + return c.json({ + aspectId, + aspectName: averageScore[0].aspectName, + assessmentId, + averageScore: averageScore.length > 0 ? averageScore[0].average : 0 + }); + } + ) + + // Get data for All Aspects average score By Assessment Id + .get( + '/average-score/aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), + async (c) => { + const { assessmentId } = c.req.param(); + + const averageScores = await db + .select({ + AspectId: aspects.id, + AspectName: aspects.name, + average: sql`AVG(options.score)` + }) + .from(answers) + .innerJoin(options, eq(answers.optionId, options.id)) + .innerJoin(questions, eq(options.questionId, questions.id)) + .innerJoin(subAspects, eq(questions.subAspectId, subAspects.id)) + .innerJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) + .where(eq(assessments.id, assessmentId)) + .groupBy(aspects.id); + + return c.json({ + assessmentId, + aspects: averageScores.map(score => ({ + AspectId: score.AspectId, + AspectName: score.AspectName, + averageScore: score.average + })) + }); + } + ) + +export default assessmentsRoute; diff --git a/apps/backend/src/routes/forgotPassword/route.ts b/apps/backend/src/routes/forgotPassword/route.ts new file mode 100644 index 0000000..8c3ddb0 --- /dev/null +++ b/apps/backend/src/routes/forgotPassword/route.ts @@ -0,0 +1,111 @@ +import { zValidator } from "@hono/zod-validator"; +import HonoEnv from "../../types/HonoEnv"; +import { z } from "zod"; +import { and, eq, isNull } from "drizzle-orm"; +import { Hono } from "hono"; +import db from "../../drizzle"; +import { users } from "../../drizzle/schema/users"; +import { notFound, unauthorized } from "../../errors/DashboardError"; +import { generateResetPasswordToken, verifyResetPasswordToken } from "../../utils/authUtils"; +import { sendResetPasswordEmail } from "../../utils/mailerUtils"; +import { hashPassword } from "../../utils/passwordUtils"; + +const forgotPasswordRoutes = new Hono() + /** + * Forgot Password + * + * Checking emails in the database, generating tokens, and sending emails occurs. + */ + .post( + '/', + zValidator( + 'json', + z.object({ + email: z.string().email(), + }) + ), + async (c) => { + const { email } = c.req.valid('json'); + + const user = await db + .select() + .from(users) + .where( + and( + isNull(users.deletedAt), + eq(users.email, email) + ) + ); + + if (!user.length) throw notFound(); + + // Generate reset password token + const resetPasswordToken = await generateResetPasswordToken({ + uid: user[0].id, + }); + + await db + .update(users) + .set({ + resetPasswordToken: resetPasswordToken + }) + .where(eq(users.email, email)); + + // Send email with reset password token + await sendResetPasswordEmail(email, resetPasswordToken); + + return c.json({ + message: 'Email has been sent successfully', + }); + } + ) + /** + * Reset Password + */ + .patch( + '/verify', + zValidator( + 'json', + z.object({ + password: z.string(), + confirm_password: z.string() + }) + ), + async (c) => { + const formData = c.req.valid('json'); + const token = c.req.query('token') + + // Token validation + if (!token) { + return c.json({ message: 'Token is required' }, 400); + } + + // Password validation + if (formData.password !== formData.confirm_password) { + return c.json({ message: 'Passwords do not match' }, 400); + } + + const decoded = await verifyResetPasswordToken(token); + if (!decoded) { + return c.json({ message: 'Invalid or expired token' }, 401); + } + + if (!decoded) throw unauthorized(); + + // Hash the password + const hashedPassword = await hashPassword(formData.password); + + await db + .update(users) + .set({ + password: hashedPassword, + updatedAt: new Date(), + }) + .where(eq(users.id, decoded.uid)); + + return c.json({ + message: 'Password has been reset successfully' + }); + }); + +export default forgotPasswordRoutes; \ No newline at end of file diff --git a/apps/backend/src/routes/managementAspect/route.ts b/apps/backend/src/routes/managementAspect/route.ts new file mode 100644 index 0000000..da8a140 --- /dev/null +++ b/apps/backend/src/routes/managementAspect/route.ts @@ -0,0 +1,492 @@ +import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; +import { Hono } from "hono"; + +import { z } from "zod"; +import db from "../../drizzle"; +import { aspects } from "../../drizzle/schema/aspects"; +import { subAspects } from "../../drizzle/schema/subAspects"; +import HonoEnv from "../../types/HonoEnv"; +import requestValidator from "../../utils/requestValidator"; +import authInfo from "../../middlewares/authInfo"; +import checkPermission from "../../middlewares/checkPermission"; +import { forbidden } from "../../errors/DashboardError"; +import { notFound } from "../../errors/DashboardError"; + +// Schema for creating and updating aspects +export const aspectFormSchema = z.object({ + name: z.string().min(1).max(50), + subAspects: z + .string() + .refine( + (data) => { + try { + const parsed = JSON.parse(data); + return Array.isArray(parsed); + } catch { + return false; + } + }, + { + message: "Sub Aspects must be an array", + } + ) + .optional(), +}); + +export const aspectUpdateSchema = aspectFormSchema.extend({ + subAspects: z.string().optional().or(z.literal("")), +}); + +// Schema for creating and updating subAspects +export const subAspectFormSchema = z.object({ + name: z.string().min(1).max(50), + aspectId: z.string().uuid(), +}); + +export const subAspectUpdateSchema = subAspectFormSchema.extend({}); + +const managementAspectRoute = new Hono() + .use(authInfo) + /** + * Get All Aspects (With Metadata) + * + * Query params: + * - includeTrashed: boolean (default: false) + * - withMetadata: boolean + */ + + // Get all aspects + .get( + "/", + checkPermission("managementAspect.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(10), + q: z.string().default(""), + }) + ), + async (c) => { + const { includeTrashed, page, limit, q } = c.req.valid("query"); + + const totalCountQuery = includeTrashed + ? sql`(SELECT count(*) FROM ${aspects})` + : sql`(SELECT count(*) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; + + const result = await db + .select({ + id: aspects.id, + name: aspects.name, + createdAt: aspects.createdAt, + updatedAt: aspects.updatedAt, + ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), + fullCount: totalCountQuery, + }) + .from(aspects) + .where( + and( + includeTrashed ? undefined : isNull(aspects.deletedAt), + q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined + ) + ) + .offset(page * limit) + .limit(limit); + + return c.json({ + data: result.map((d) => ({ ...d, fullCount: undefined })), + _metadata: { + currentPage: page, + totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit), + totalItems: Number(result[0]?.fullCount) ?? 0, + perPage: limit, + }, + }); + } + ) + + // Get aspect by id + .get( + "/:id", + checkPermission("managementAspect.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z.string().default("false"), + }) + ), + async (c) => { + const aspectId = c.req.param("id"); + + if (!aspectId) + throw notFound({ + message: "Missing id", + }); + + const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true"; + + const queryResult = await db + .select({ + id: aspects.id, + name: aspects.name, + createdAt: aspects.createdAt, + updatedAt: aspects.updatedAt, + ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), + subAspect: { + name: subAspects.name, + id: subAspects.id, + }, + }) + .from(aspects) + .leftJoin(subAspects, eq(aspects.id, subAspects.aspectId)) + .where(and(eq(aspects.id, aspectId), !includeTrashed ? isNull(aspects.deletedAt) : undefined)); + + if (!queryResult.length) + throw forbidden({ + message: "The aspect does not exist", + }); + + const subAspectsList = queryResult.reduce((prev, curr) => { + if (!curr.subAspect) return prev; + prev.set(curr.subAspect.id, curr.subAspect.name); + return prev; + }, new Map()); // Map + + const aspectData = { + ...queryResult[0], + subAspect: undefined, + subAspects: Array.from(subAspectsList, ([id, name]) => ({ id, name })), + }; + + return c.json(aspectData); + } + ) + + // Create aspect + .post("/", + checkPermission("managementAspect.create"), + requestValidator("json", aspectFormSchema), + async (c) => { + const aspectData = c.req.valid("json"); + + // Validation to check if the aspect name already exists + const existingAspect = await db + .select() + .from(aspects) + .where(eq(aspects.name, aspectData.name)); + + if (existingAspect.length > 0) { + throw forbidden({ + message: "Aspect name already exists", + }); + } + + const aspect = await db + .insert(aspects) + .values({ + name: aspectData.name, + }) + .returning(); + + // if sub-aspects are available, parse them into a string array + if (aspectData.subAspects) { + const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; + // if there are sub-aspects, insert them into the database + if (subAspectsArray.length) { + await db.insert(subAspects).values( + subAspectsArray.map((subAspect) => ({ + aspectId: aspect[0].id, + name: subAspect, + })) + ); + } + } + + return c.json( + { + message: "Aspect created successfully", + }, + 201 + ); + } + ) + + // Update aspect + .patch( + "/:id", + checkPermission("managementAspect.update"), + requestValidator("json", aspectUpdateSchema), + async (c) => { + const aspectId = c.req.param("id"); + const aspectData = c.req.valid("json"); + + // Validation to check if the new aspect name already exists + const existingAspect = await db + .select() + .from(aspects) + .where( + and( + eq(aspects.name, aspectData.name), + isNull(aspects.deletedAt), + sql`${aspects.id} <> ${aspectId}` + ) + ); + + if (existingAspect.length > 0) { + throw forbidden({ + message: "Aspect name already exists", + }); + } + + const aspect = await db + .select() + .from(aspects) + .where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt))); + + if (!aspect[0]) throw notFound(); + + await db + .update(aspects) + .set({ + ...aspectData, + updatedAt: new Date(), + }) + .where(eq(aspects.id, aspectId)); + + //Update for Sub-Aspects + // if (aspectData.subAspects) { + // const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; + + // await db.delete(subAspects).where(eq(subAspects.aspectId, aspectId)); + + // if (subAspectsArray.length) { + // await db.insert(subAspects).values( + // subAspectsArray.map((subAspect) => ({ + // aspectId: aspectId, + // name: subAspect, + // })) + // ); + // } + // } + + return c.json({ + message: "Aspect updated successfully", + }); + } + ) + + // Delete aspect + .delete( + "/:id", + checkPermission("managementAspect.delete"), + requestValidator( + "form", + z.object({ + // skipTrash: z.string().default("false"), + }) + ), + async (c) => { + const aspectId = c.req.param("id"); + + // const skipTrash = c.req.valid("form").skipTrash.toLowerCase() === "true"; + + const aspect = await db + .select() + .from(aspects) + .where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt))); + + if (!aspect[0]) + throw notFound({ + message: "The aspect is not found", + }); + + await db + .update(aspects) + .set({ + deletedAt: new Date(), + }) + .where(eq(aspects.id, aspectId)); + + return c.json({ + message: "Aspect deleted successfully", + }); + } + ) + + // Undo delete + .patch( + "/restore/:id", + checkPermission("managementAspect.restore"), + async (c) => { + const aspectId = c.req.param("id"); + + const aspect = (await db.select().from(aspects).where(eq(aspects.id, aspectId)))[0]; + + if (!aspect) throw notFound(); + + if (!aspect.deletedAt) { + throw forbidden({ + message: "The aspect is not deleted", + }); + } + + await db.update(aspects).set({ deletedAt: null }).where(eq(aspects.id, aspectId)); + + return c.json({ + message: "Aspect restored successfully", + }); + } + ) + + // Get sub aspects by aspect ID + .get( + "/subAspects/:aspectId", + checkPermission("managementAspect.readAll"), + async (c) => { + const aspectId = c.req.param("aspectId"); + + const aspect = await db + .select() + .from(aspects) + .where(and( + eq(aspects.id, aspectId), + isNull(aspects.deletedAt))); + + if (!aspect[0]) + throw notFound({ + message: "The aspect is not found", + }); + + const subAspectsData = await db + .select() + .from(subAspects) + .where(eq(subAspects.aspectId, aspectId)); + + return c.json({ + subAspects: subAspectsData, + }); + } + ) + + // Create sub aspect + .post( + "/subAspect", + checkPermission("managementAspect.create"), + requestValidator("json", subAspectFormSchema), + async (c) => { + const subAspectData = c.req.valid("json"); + + // Validation to check if the sub aspect name already exists + const existingSubAspect = await db + .select() + .from(subAspects) + .where( + and( + eq(subAspects.name, subAspectData.name), + eq(subAspects.aspectId, subAspectData.aspectId))); + + if (existingSubAspect.length > 0) { + throw forbidden({ message: "Nama Sub Aspek sudah tersedia!" }); + } + + const [aspect] = await db + .select() + .from(aspects) + .where( + and( + eq(aspects.id, subAspectData.aspectId), + isNull(aspects.deletedAt))); + + if (!aspect) + throw forbidden({ + message: "The aspect is not found", + }); + + await db.insert(subAspects).values(subAspectData); + + return c.json( + { + message: "Sub aspect created successfully", + }, + 201 + ); + } + ) + + // Update sub aspect + .patch( + "/subAspect/:id", checkPermission("managementAspect.update"), + requestValidator("json", subAspectUpdateSchema), + async (c) => { + const subAspectId = c.req.param("id"); + const subAspectData = c.req.valid("json"); + + // Validation to check if the new sub aspect name already exists + const existingSubAspect = await db + .select() + .from(subAspects) + .where( + eq(subAspects.aspectId, subAspectData.aspectId)); + + if (existingSubAspect.length > 0) { + throw forbidden({ message: "Name Sub Aspect already exists" }); + } + + if (!existingSubAspect[0]) + throw notFound({ + message: "The sub aspect is not found", + }); + + await db + .update(subAspects) + .set({ + ...subAspectData, + updatedAt: new Date(), + }) + .where(eq(subAspects.id, subAspectId)); + + return c.json({ + message: "Sub aspect updated successfully", + }); + }) + + // Delete sub aspect + .delete( + "/subAspect/:id", + checkPermission("managementAspect.delete"), + async (c) => { + const subAspectId = c.req.param("id"); + + const subAspect = await db + .select() + .from(subAspects) + .where(eq(subAspects.id, subAspectId)); + + if (!subAspect[0]) + throw notFound({ + message: "The sub aspect is not found", + }); + + await db + .update(subAspects) + .set({ + deletedAt: new Date(), + }) + .where(eq(subAspects.id, subAspectId)); + // await db.delete(subAspects).where(eq(subAspects.id, subAspectId)); + + return c.json({ + message: "Sub aspect deleted successfully", + }); + } + ); + +export default managementAspectRoute; diff --git a/apps/backend/src/utils/authUtils.ts b/apps/backend/src/utils/authUtils.ts index 99019f4..09f19b3 100644 --- a/apps/backend/src/utils/authUtils.ts +++ b/apps/backend/src/utils/authUtils.ts @@ -4,6 +4,7 @@ import appEnv from "../appEnv"; // Environment variables for secrets, defaulting to a random secret if not set. const accessTokenSecret = appEnv.ACCESS_TOKEN_SECRET; const refreshTokenSecret = appEnv.REFRESH_TOKEN_SECRET; +const resetPasswordTokenSecret = appEnv.RESET_PASSWORD_TOKEN_SECRET; // Algorithm to be used for JWT encoding. const algorithm: jwt.Algorithm = "HS256"; @@ -11,6 +12,7 @@ const algorithm: jwt.Algorithm = "HS256"; // Expiry settings for tokens. 'null' signifies no expiry. export const accessTokenExpiry: number | string | null = null; export const refreshTokenExpiry: number | string | null = "30d"; +export const resetPasswordTokenExpiry: number | string | null = null; // Interfaces to describe the payload structure for access and refresh tokens. interface AccessTokenPayload { @@ -21,6 +23,10 @@ interface RefreshTokenPayload { uid: string; } +interface ResetPasswordTokenPayload { + uid: string; +} + /** * Generates a JSON Web Token (JWT) for access control using a specified payload. * @@ -84,3 +90,35 @@ export const verifyRefreshToken = async (token: string) => { return null; } }; + +/** + * Generates a JSON Web Token (JWT) for reset password using a specified payload. + * + * @param payload - The payload containing user-specific data for the token. + * @returns A promise that resolves to the generated JWT string. + */ +export const generateResetPasswordToken = async (payload: ResetPasswordTokenPayload) => { + const token = jwt.sign(payload, resetPasswordTokenSecret, { + algorithm, + ...(resetPasswordTokenExpiry ? { expiresIn: resetPasswordTokenExpiry } : {}), + }); + return token; +}; + +/** + * Verifies a given reset password token and decodes the payload if the token is valid. + * + * @param token - The JWT string to verify. + * @returns A promise that resolves to the decoded payload or null if verification fails. + */ +export const verifyResetPasswordToken = async (token: string) => { + try { + const payload = jwt.verify( + token, + resetPasswordTokenSecret + ) as ResetPasswordTokenPayload; + return payload; + } catch { + return null; + } +}; \ No newline at end of file diff --git a/apps/backend/src/utils/mailerUtils.ts b/apps/backend/src/utils/mailerUtils.ts new file mode 100644 index 0000000..4f5d660 --- /dev/null +++ b/apps/backend/src/utils/mailerUtils.ts @@ -0,0 +1,33 @@ +import nodemailer from 'nodemailer'; +import appEnv from '../appEnv'; + +/** + * Nodemailer configuration + */ +const transporter = nodemailer.createTransport({ + host: appEnv.SMTP_HOST, + port: appEnv.SMTP_PORT, + secure: false, + auth: { + user: appEnv.SMTP_USERNAME, + pass: appEnv.SMTP_PASSWORD, + }, + tls: { + rejectUnauthorized: false, + }, +}); + +export async function sendResetPasswordEmail(to: string, token: string) { + const resetUrl = appEnv.BASE_URL + '/forgot-password/verify?token=' + token; + + const info = await transporter.sendMail({ + from: `"Your App" <${appEnv.SMTP_USERNAME}>`, + to, + subject: 'Password Reset Request', + text: `You requested a password reset. Click this link to reset your password: ${resetUrl}`, + html: `

You requested a password reset. Click this link to reset your password:
${resetUrl}

`, + }); + + console.log('Email sent: %s', info.messageId); + return info; +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 4ef493a..37d7123 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -17,6 +17,7 @@ "@mantine/hooks": "^7.10.2", "@mantine/notifications": "^7.10.2", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.45.0", @@ -31,7 +32,7 @@ "mantine-form-zod-resolver": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-hook-form": "^7.52.2", + "react-hook-form": "^7.53.0", "react-icons": "^5.2.1", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", diff --git a/apps/frontend/src/assets/backgrounds/backgroundLogin.png b/apps/frontend/src/assets/backgrounds/backgroundLogin.png new file mode 100644 index 0000000..3eba5c9 Binary files /dev/null and b/apps/frontend/src/assets/backgrounds/backgroundLogin.png differ diff --git a/apps/frontend/src/assets/backgrounds/backgroundLoginMobile.png b/apps/frontend/src/assets/backgrounds/backgroundLoginMobile.png new file mode 100644 index 0000000..3032a38 Binary files /dev/null and b/apps/frontend/src/assets/backgrounds/backgroundLoginMobile.png differ diff --git a/apps/frontend/src/routes/forgot-password/index.lazy.tsx b/apps/frontend/src/routes/forgot-password/index.lazy.tsx new file mode 100644 index 0000000..8f0a73e --- /dev/null +++ b/apps/frontend/src/routes/forgot-password/index.lazy.tsx @@ -0,0 +1,242 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { TbArrowNarrowRight } from "react-icons/tb"; +import { IoIosArrowUp } from "react-icons/io"; +import { HiOutlineGlobeAlt } from "react-icons/hi"; +import { useForm, Control, FieldError } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/shadcn/components/ui/button.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shadcn/components/ui/form.tsx"; +import { Input } from "@/shadcn/components/ui/input.tsx"; +import client from "@/honoClient"; +import { useState } from "react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu"; + +// Define validation schema using zod +const formSchema = z.object({ + email: z.string().email(), +}); + +type FormSchema = z.infer; + +// Interface for props of CustomFormField +interface CustomFormFieldProps { + name: keyof FormSchema; + label: string; + control: Control; + type?: string; + placeholder?: string; + error?: FieldError; // Add error prop +} + +// Component for form fields with bold labels +const CustomFormField: React.FC = ({ + name, + label, + control, + type = "text", + placeholder, + error, +}) => ( + ( + + {label} + + + + + + )} + /> +); + +interface DropdownProps { + onSelect: (selectedOption: string) => void; + defaultOption?: string; + listOption?: string[]; +} + +const CustomDropdownMenu: React.FC = ({ + onSelect, + defaultOption = "", + listOption = [], +}) => { + const [selectedOption, setSelectedOption] = useState(defaultOption); + + const handleSelect = (option: string) => { + setSelectedOption(option); + onSelect(option); + }; + + return ( + + + + + + + {listOption.map((option, index) => ( + + {option} + + ))} + + + + ); +}; + +// Define a route for the registration form +export const Route = createLazyFileRoute("/forgot-password/")({ + component: () => ( +
+ +
+ ), +}); + +// Main components of the registration form +export function ForgotPasswordForm() { + // Set up form with react-hook-form and zod + const form = useForm({ + // Integrate schema with form + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + }, + }); + + // Function to handle form submission + const onSubmit = async (values: FormSchema) => { + try { + const response = await client["forgot-password"].$post({ + json: values, + }); + + if (response.ok) { + // Handle successful registration here + alert("Reset instructions sent successfully"); + const data = await response.json(); + return data; + } else { + throw response; + } + } catch (error) { + // Handle registration error here + console.error("Submission error:", error); + alert("Failed to send reset instructions"); + } + }; + + const handleSelect = (selectedOption: string) => { + console.log('Selected option:', selectedOption); + // Do something with selected option + }; + + return ( +
+
+
+
+
+
+
+
+
+
+
+ +
+ {/* Top */} +
Amati
+ + {/* Center */} +
+
+

+ Forgot Password +

+

+ No worries, we'll send you reset instructions +

+
+
+ +
+ +
+
+ + + Back to login + +
+
+ +
+ + {/* Bottom */} +
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/routes/forgot-password/verify.lazy.tsx b/apps/frontend/src/routes/forgot-password/verify.lazy.tsx new file mode 100644 index 0000000..23d5b94 --- /dev/null +++ b/apps/frontend/src/routes/forgot-password/verify.lazy.tsx @@ -0,0 +1,283 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { TbArrowNarrowRight } from "react-icons/tb"; +import { useForm, Control, FieldError } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/shadcn/components/ui/button.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/shadcn/components/ui/form.tsx"; +import { Input } from "@/shadcn/components/ui/input.tsx"; +import { useEffect, useState } from "react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu"; +import { HiOutlineGlobeAlt } from "react-icons/hi"; +import { IoIosArrowUp } from "react-icons/io"; + +/// Define validation schema using zod +const formSchema = z + .object({ + password: z.string().min(1, "Password is required"), + confirm_password: z.string().min(1, "Password confirmation is required"), + }) + .refine((data) => data.password === data.confirm_password, { + message: "Passwords do not match", + path: ["confirm_password"], // Set error on confirm_password field + }); + +type FormSchema = z.infer; + +// Interface for props of CustomFormField +interface CustomFormFieldProps { + name: keyof FormSchema; + label: string; + control: Control; + type?: string; + placeholder?: string; + error?: FieldError; +} + +// Component for form fields with bold labels +const CustomFormField: React.FC = ({ + name, + label, + control, + type = "text", + placeholder, + error, +}) => ( + ( + + {label} + + + + {error &&

{error.message}

} +
+ )} + /> +); + +interface DropdownProps { + onSelect: (selectedOption: string) => void; + defaultOption?: string; + listOption?: string[]; +} + +const CustomDropdownMenu: React.FC = ({ + onSelect, + defaultOption = "", + listOption = [], +}) => { + const [selectedOption, setSelectedOption] = useState(defaultOption); + + const handleSelect = (option: string) => { + setSelectedOption(option); + onSelect(option); + }; + + return ( + + + + + + + {listOption.map((option, index) => ( + + {option} + + ))} + + + + ); +}; + +// Define a route for the registration form +export const Route = createLazyFileRoute("/forgot-password/verify")({ + component: () => ( +
+ +
+ ), +}); + +// Main component of the reset password form +export function ResetPasswordForm() { + const [token, setToken] = useState(null); + + // Set up form with react-hook-form and zod + const form = useForm({ + // Integrate schema with form + resolver: zodResolver(formSchema), + defaultValues: { + password: "", + confirm_password: "", + }, + }); + + // Use effect to get token from URL when component mounts + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const tokenFromURL = urlParams.get("token"); + setToken(tokenFromURL); + }, []); + + // Function to handle form submission + const onSubmit = async (values: FormSchema) => { + try { + if (!token) { + alert("Token not found in URL"); + return; + } + + // Create URL with token as query parameter + const urlWithToken = import.meta.env.VITE_BACKEND_BASE_URL + `/forgot-password/verify?token=${token}`; + + const response = await fetch(urlWithToken, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + + // Check status response + if (response.ok) { + // Periksa apakah respons memiliki konten + const responseText = await response.text(); + let data = {}; + try { + data = responseText ? JSON.parse(responseText) : {}; // Parsing respons hanya jika ada teks + } catch (jsonError) { + console.error("Error parsing JSON response:", jsonError); + alert("Failed to parse server response"); + } + alert("Password reset successfully"); + return data; + } else { + // Tangani kasus jika respons tidak OK + const errorText = await response.text(); + console.error("Server error:", errorText); + alert("Failed to reset password"); + } + } catch (error) { + console.error("Submission error:", error); + alert("Failed to reset password"); + } + }; + + const handleSelect = (selectedOption: string) => { + console.log('Selected option:', selectedOption); + // Do something with selected option + }; + + return ( +
+
+
+
+
+
+
+
+
+
+
+ +
+ {/* Top */} +
Amati
+ + {/* Center */} +
+
+

+ Change Password +

+

Enter your new password

+
+
+ +
+ + +
+
+ + + Back to login + +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/routes/login/index.lazy.tsx b/apps/frontend/src/routes/login/index.lazy.tsx index bc9d30d..58c16ef 100644 --- a/apps/frontend/src/routes/login/index.lazy.tsx +++ b/apps/frontend/src/routes/login/index.lazy.tsx @@ -1,21 +1,24 @@ import { createLazyFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; -import { - Paper, - PasswordInput, - Stack, - Text, - TextInput, - Group, - Button, - Alert, -} from "@mantine/core"; +import { Input } from '@/shadcn/components/ui/input.tsx'; +import { Button } from '@/shadcn/components/ui/button.tsx'; +import { Alert } from '@/shadcn/components/ui/alert.tsx'; +import { Card } from '@/shadcn/components/ui/card.tsx'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/shadcn/components/ui/form.tsx'; import client from "../../honoClient"; -import { useForm } from "@mantine/form"; +import { useForm } from "react-hook-form"; import { z } from "zod"; -import { zodResolver } from "mantine-form-zod-resolver"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import useAuth from "@/hooks/useAuth"; +import { TbArrowNarrowRight } from "react-icons/tb"; export const Route = createLazyFileRoute("/login/")({ component: LoginPage, @@ -38,11 +41,11 @@ export default function LoginPage() { const { isAuthenticated, saveAuthData } = useAuth(); const form = useForm({ - initialValues: { + resolver: zodResolver(formSchema), + defaultValues: { username: "", password: "", }, - validate: zodResolver(formSchema), }); useEffect(() => { @@ -94,63 +97,94 @@ export default function LoginPage() { }; return ( -
- - - Welcome - -
- - {errorMessage ? ( - - {errorMessage} - - ) : null} - - - - - - {/* +
+ Amati +
+
+ +

Sign In

+

+ New to this app?{' '} + - Don't have an account? Register - */} -

- - - - - + Register now + +

+
+ +
+ {errorMessage && ( + +

{errorMessage}

+
+ )} + ( + + Email/Username + + + + + + )} + /> + ( + + Password + + + + + + )} + /> +

+ + Forgot Password? + +

+
+
+ +
+
+ + +
); } diff --git a/apps/frontend/src/shadcn/components/ui/dropdown-menu.tsx b/apps/frontend/src/shadcn/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..f69a0d6 --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/apps/frontend/tailwind.config.js b/apps/frontend/tailwind.config.js index 7cb7e37..51535f1 100644 --- a/apps/frontend/tailwind.config.js +++ b/apps/frontend/tailwind.config.js @@ -72,6 +72,16 @@ module.exports = { "accordion-up": "accordion-up 0.2s ease-out", }, }, + letterSpacing: { + tightest: '-.075em', + tighter: '-.05em', + tight: '-.025em', + normal: '0', + wide: '.025em', + wider: '.05em', + widest: '.1em', + widest: '.25em', + }, }, plugins: [require("tailwindcss-animate")], } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cb8007..dd01011 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: moment: specifier: ^2.30.1 version: 2.30.1 + nodemailer: + specifier: ^6.9.14 + version: 6.9.14 postgres: specifier: ^3.4.4 version: 3.4.4 @@ -69,6 +72,9 @@ importers: '@types/node': specifier: ^20.14.2 version: 20.14.2 + '@types/nodemailer': + specifier: ^6.4.15 + version: 6.4.15 drizzle-kit: specifier: ^0.22.7 version: 0.22.7 @@ -89,7 +95,7 @@ importers: version: 11.11.4(@types/react@18.3.3)(react@18.3.1) '@hookform/resolvers': specifier: ^3.9.0 - version: 3.9.0(react-hook-form@7.52.2(react@18.3.1)) + version: 3.9.0(react-hook-form@7.53.0(react@18.3.1)) '@mantine/core': specifier: ^7.10.2 version: 7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -108,6 +114,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.1 + version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.0 version: 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -151,8 +160,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-hook-form: - specifier: ^7.52.2 - version: 7.52.2(react@18.3.1) + specifier: ^7.53.0 + version: 7.53.0(react@18.3.1) react-icons: specifier: ^5.2.1 version: 5.2.1(react@18.3.1) @@ -1453,6 +1462,19 @@ packages: '@radix-ui/primitive@1.1.0': resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + '@radix-ui/react-arrow@1.1.0': + resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.1.1': resolution: {integrity: sha512-0i/EKJ222Afa1FE0C6pNJxDq1itzcl3HChE9DwskA4th4KRse8ojx8a1nVcOjwJdbpDLcz7uol77yYnQNMHdKw==} peerDependencies: @@ -1466,6 +1488,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.0': + resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.0': resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} peerDependencies: @@ -1484,6 +1519,72 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.0': + resolution: {integrity: sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.1': + resolution: {integrity: sha512-y8E+x9fBq9qvteD2Zwa4397pUVhYsh9iq44b5RD5qu1GMJWBCBuVg1hMyItbc6+zH00TxGRqd9Iot4wzf3OoBQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.0': + resolution: {integrity: sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.0': + resolution: {integrity: sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-label@2.1.0': resolution: {integrity: sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==} peerDependencies: @@ -1497,6 +1598,45 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.1': + resolution: {integrity: sha512-oa3mXRRVjHi6DZu/ghuzdylyjaMXLymx83irM7hTxutQbD+7IhPKdMdRHD26Rm+kHRrWcrUkkRPv5pd47a2xFQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.0': + resolution: {integrity: sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.1': + resolution: {integrity: sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.0': resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==} peerDependencies: @@ -1523,6 +1663,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.0': + resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.0': resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} peerDependencies: @@ -1550,6 +1703,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-escape-keydown@1.1.0': + resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.0': resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} peerDependencies: @@ -1568,6 +1730,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.0': + resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.0': resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} peerDependencies: @@ -1577,6 +1748,9 @@ packages: '@types/react': optional: true + '@radix-ui/rect@1.1.0': + resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@rollup/rollup-android-arm-eabi@4.18.0': resolution: {integrity: sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==} cpu: [arm] @@ -1887,6 +2061,9 @@ packages: '@types/node@20.14.2': resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} + '@types/nodemailer@6.4.15': + resolution: {integrity: sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -2284,6 +2461,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -4177,6 +4358,10 @@ packages: node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + nodemailer@6.9.14: + resolution: {integrity: sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==} + engines: {node: '>=6.0.0'} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -4528,8 +4713,8 @@ packages: peerDependencies: react: ^18.3.1 - react-hook-form@7.52.2: - resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} + react-hook-form@7.53.0: + resolution: {integrity: sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -4575,6 +4760,16 @@ packages: '@types/react': optional: true + react-remove-scroll@2.5.7: + resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-style-singleton@2.2.1: resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -6201,9 +6396,9 @@ snapshots: hono: 4.4.6 zod: 3.23.8 - '@hookform/resolvers@3.9.0(react-hook-form@7.52.2(react@18.3.1))': + '@hookform/resolvers@3.9.0(react-hook-form@7.53.0(react@18.3.1))': dependencies: - react-hook-form: 7.52.2(react@18.3.1) + react-hook-form: 7.53.0(react@18.3.1) '@humanwhocodes/config-array@0.11.14': dependencies: @@ -6614,6 +6809,15 @@ snapshots: '@radix-ui/primitive@1.1.0': {} + '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-checkbox@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -6630,6 +6834,18 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1 @@ -6642,6 +6858,64 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + + '@radix-ui/react-dismissable-layer@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-dropdown-menu@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-menu': 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-focus-guards@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + + '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-id@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + '@radix-ui/react-label@2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -6651,6 +6925,60 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-menu@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-popper@1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/rect': 1.1.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-portal@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-presence@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) @@ -6670,6 +6998,23 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slot@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) @@ -6690,6 +7035,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + '@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1 @@ -6702,6 +7054,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-use-rect@1.1.0(@types/react@18.3.3)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.0 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + '@radix-ui/react-use-size@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) @@ -6709,6 +7068,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/rect@1.1.0': {} + '@rollup/rollup-android-arm-eabi@4.18.0': optional: true @@ -6985,7 +7346,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 20.11.24 + '@types/node': 20.14.2 '@types/graceful-fs@4.1.9': dependencies: @@ -7030,6 +7391,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/nodemailer@6.4.15': + dependencies: + '@types/node': 20.14.2 + '@types/normalize-package-data@2.4.4': {} '@types/parse-json@4.0.2': {} @@ -7066,7 +7431,7 @@ snapshots: '@types/through@0.0.30': dependencies: - '@types/node': 20.11.24 + '@types/node': 20.14.2 '@types/tinycolor2@1.4.6': {} @@ -7542,6 +7907,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.4: + dependencies: + tslib: 2.6.3 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -7624,7 +7993,7 @@ snapshots: ast-types@0.13.4: dependencies: - tslib: 2.6.2 + tslib: 2.6.3 autoprefixer@10.4.19(postcss@8.4.38): dependencies: @@ -9942,6 +10311,8 @@ snapshots: node-releases@2.0.14: {} + nodemailer@6.9.14: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -10321,7 +10692,7 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-hook-form@7.52.2(react@18.3.1): + react-hook-form@7.53.0(react@18.3.1): dependencies: react: 18.3.1 @@ -10361,6 +10732,17 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + react-remove-scroll@2.5.7(@types/react@18.3.3)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.6(@types/react@18.3.3)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.3.1) + tslib: 2.6.3 + use-callback-ref: 1.3.2(@types/react@18.3.3)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.3)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + react-style-singleton@2.2.1(@types/react@18.3.3)(react@18.3.1): dependencies: get-nonce: 1.0.1