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 a048ea3..d0a7c76 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -62,6 +62,48 @@ const permissionsData = [ { 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 4d7b670..925b235 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -18,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"; import assessmentsRequestManagementRoutes from "./routes/assessmentRequestManagement/route"; configDotenv(); @@ -85,6 +89,10 @@ const routes = app .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/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 b89c51a..37d7123 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,11 +10,15 @@ }, "dependencies": { "@emotion/react": "^11.11.4", + "@hookform/resolvers": "^3.9.0", "@mantine/core": "^7.10.2", "@mantine/dates": "^7.10.2", "@mantine/form": "^7.10.2", "@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", "@tanstack/react-router": "^1.38.1", @@ -28,6 +32,7 @@ "mantine-form-zod-resolver": "^1.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "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/alert.tsx b/apps/frontend/src/shadcn/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/frontend/src/shadcn/components/ui/card.tsx b/apps/frontend/src/shadcn/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/apps/frontend/src/shadcn/components/ui/checkbox.tsx b/apps/frontend/src/shadcn/components/ui/checkbox.tsx new file mode 100644 index 0000000..df61a13 --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } 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/src/shadcn/components/ui/form.tsx b/apps/frontend/src/shadcn/components/ui/form.tsx new file mode 100644 index 0000000..44dc758 --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/shadcn/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +