diff --git a/backend/user/drizzle/0001_add_admin_user.sql b/backend/user/drizzle/0001_add_admin_user.sql new file mode 100644 index 0000000000..14989f482a --- /dev/null +++ b/backend/user/drizzle/0001_add_admin_user.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "is_admin" boolean DEFAULT false; \ No newline at end of file diff --git a/backend/user/drizzle/meta/0001_snapshot.json b/backend/user/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..1e2c2f42be --- /dev/null +++ b/backend/user/drizzle/meta/0001_snapshot.json @@ -0,0 +1,143 @@ +{ + "id": "5293f5bb-f4d5-43a4-b2bf-f6ebb241dde1", + "prevId": "f01e3d91-1038-48b8-a073-a6a9a8a308fd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin": { + "name": "admin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "action": { + "name": "action", + "type": "action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "unlock_time": { + "name": "unlock_time", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "attempted_questions": { + "name": "attempted_questions", + "type": "integer[]", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": { + "public.action": { + "name": "action", + "schema": "public", + "values": [ + "SEED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/user/drizzle/meta/_journal.json b/backend/user/drizzle/meta/_journal.json index e900747ac9..ed929bac0b 100644 --- a/backend/user/drizzle/meta/_journal.json +++ b/backend/user/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1728143079049, "tag": "0000_initial_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1730968227580, + "tag": "0001_add_admin_user", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/user/src/controllers/auth-check/index.ts b/backend/user/src/controllers/auth-check/index.ts index 3b7354d43d..eccbdabd26 100644 --- a/backend/user/src/controllers/auth-check/index.ts +++ b/backend/user/src/controllers/auth-check/index.ts @@ -16,7 +16,7 @@ export const checkIsAuthed: IRouteHandler = async (req, res) => { '[/auth-check/check-is-authed]: Expires At ' + new Date(expireTimeInMillis).toLocaleString() ); const user = await db - .select({ name: users.username }) + .select({ name: users.username, isAdmin: users.isAdmin, email: users.email }) .from(users) .where(eq(users.id, decoded.id)) .limit(1); @@ -25,6 +25,8 @@ export const checkIsAuthed: IRouteHandler = async (req, res) => { expiresAt: expireTimeInMillis, userId: decoded.id, username: user.length > 0 ? user[0].name : undefined, + email: user.length > 0 ? user[0].email : undefined, + isAdmin: user.length > 0 ? user[0].isAdmin : undefined, }); } diff --git a/backend/user/src/lib/db/schema.ts b/backend/user/src/lib/db/schema.ts index 788efb6362..e4743c1731 100644 --- a/backend/user/src/lib/db/schema.ts +++ b/backend/user/src/lib/db/schema.ts @@ -1,4 +1,13 @@ -import { integer, pgEnum, pgTable, smallint, timestamp, uuid, varchar } from 'drizzle-orm/pg-core'; +import { + boolean, + integer, + pgEnum, + pgTable, + smallint, + timestamp, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; // Define the user table export const users = pgTable('users', { @@ -11,6 +20,7 @@ export const users = pgTable('users', { failedAttempts: smallint('failed_attempts').default(0), // Failed counts unlockTime: timestamp('unlock_time', { precision: 6, withTimezone: true }), // If failed counts > limit, block all attempts until this time. attemptedQuestions: integer('attempted_questions').array(), + isAdmin: boolean('is_admin').default(false), }); export const actionEnum = pgEnum('action', ['SEED']); diff --git a/backend/user/src/lib/db/seed.ts b/backend/user/src/lib/db/seed.ts index a87aaccc45..1595b03495 100644 --- a/backend/user/src/lib/db/seed.ts +++ b/backend/user/src/lib/db/seed.ts @@ -1,10 +1,10 @@ -import { eq } from 'drizzle-orm'; +import { eq, InferInsertModel } from 'drizzle-orm'; import { generatePasswordHash } from '@/lib/passwords'; import { admin as adminTable, db, users as usersTable } from '.'; -const TEST_USER_CREDENTIALS = [ +const TEST_USER_CREDENTIALS: Array> = [ { username: 'testuser01', email: 'test_user_01@email.com', @@ -19,6 +19,14 @@ const TEST_USER_CREDENTIALS = [ lastName: 'user02', password: '123456789', // For local testing purposes }, + { + username: 'adminuser01', + email: 'admin_user@email.com', + firstName: 'admin', + lastName: 'user01', + password: 'IamPeerprepAdmin!9', + isAdmin: true, + }, ]; const main = async () => { diff --git a/frontend/.env.local b/frontend/.env.local deleted file mode 100644 index d2ffc87951..0000000000 --- a/frontend/.env.local +++ /dev/null @@ -1,7 +0,0 @@ -FRONTEND_ENV=local - -VITE_USER_SERVICE=http://localhost:9001 -VITE_QUESTION_SERVICE=http://localhost:9002 -VITE_COLLAB_SERVICE=http://localhost:9003 -VITE_MATCHING_SERVICE=http://localhost:9004 -VITE_CHAT_SERVICE=http://localhost:9005 diff --git a/frontend/package.json b/frontend/package.json index 9e790a43d3..3fd0f5add8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,6 +82,6 @@ "tailwindcss": "^3.4.11", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", - "vite": "^5.4.1" + "vite": "^5.4.10" } } diff --git a/frontend/src/components/blocks/authed/with-nav-blocker.tsx b/frontend/src/components/blocks/authed/with-nav-blocker.tsx index b79aa169ee..b8e9ba7e28 100644 --- a/frontend/src/components/blocks/authed/with-nav-blocker.tsx +++ b/frontend/src/components/blocks/authed/with-nav-blocker.tsx @@ -1,8 +1,15 @@ +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { FC, PropsWithChildren } from 'react'; import { useBlocker } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog'; export const WithNavBlocker: FC = ({ children }) => { const blocker = useBlocker( @@ -13,21 +20,32 @@ export const WithNavBlocker: FC = ({ children }) => { {blocker.state === 'blocked' && ( -

+ Are you sure you want to navigate away from this page? -

-
- - -
-
+ + + + + +
+ + +
+
+
)} diff --git a/frontend/src/components/blocks/interview/chat/chat-layout.tsx b/frontend/src/components/blocks/interview/chat/chat-layout.tsx index 8631b12ad4..13f29de57e 100644 --- a/frontend/src/components/blocks/interview/chat/chat-layout.tsx +++ b/frontend/src/components/blocks/interview/chat/chat-layout.tsx @@ -110,15 +110,15 @@ export const ChatLayout = ({ - + - Clear Chat History + Clear Chat History Are you sure you want to clear the chat history? This action cannot be undone. - Cancel + Cancel Clear History diff --git a/frontend/src/components/blocks/interview/chat/chat-markdown.tsx b/frontend/src/components/blocks/interview/chat/chat-markdown.tsx index 7d08af71c7..992fa0619a 100644 --- a/frontend/src/components/blocks/interview/chat/chat-markdown.tsx +++ b/frontend/src/components/blocks/interview/chat/chat-markdown.tsx @@ -31,7 +31,15 @@ export const MarkdownComponent = ({ code({ children, className, ...rest }) { const [copyCodeText, setCopyCodeText] = useState('Copy Code'); + const match = /language-(\w+)/.exec(className || ''); + const onCopy = (code: string) => { + const language = match?.[1]; + + if (language) { + localStorage.setItem('ai-asst-lang', language); + } + navigator.clipboard.writeText(code); setCopyCodeText('Copied!'); setTimeout(() => { @@ -39,7 +47,6 @@ export const MarkdownComponent = ({ }, 3000); }; - const match = /language-(\w+)/.exec(className || ''); return match ? (
diff --git a/frontend/src/components/blocks/interview/editor.tsx b/frontend/src/components/blocks/interview/editor.tsx index 664ab34613..e017bfa72f 100644 --- a/frontend/src/components/blocks/interview/editor.tsx +++ b/frontend/src/components/blocks/interview/editor.tsx @@ -193,6 +193,14 @@ export const Editor = ({ height={`${Math.max((height as number) - EXTENSION_HEIGHT, MIN_EDITOR_HEIGHT)}px`} value={code} onChange={handleCodeChange} + onPaste={(_event) => { + const lang = localStorage.getItem('ai-asst-lang'); + + if (lang) { + setLanguage(lang as LanguageName); + localStorage.removeItem('ai-assist-lang'); + } + }} theme={themePreset} lang={language} basicSetup={{ diff --git a/frontend/src/components/blocks/interview/question-attempts/attempt-details/code-viewer.tsx b/frontend/src/components/blocks/interview/question-attempts/attempt-details/code-viewer.tsx index ffbe70a7ce..4dec8573d2 100644 --- a/frontend/src/components/blocks/interview/question-attempts/attempt-details/code-viewer.tsx +++ b/frontend/src/components/blocks/interview/question-attempts/attempt-details/code-viewer.tsx @@ -4,6 +4,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; type ICodeProps = { code: string; @@ -24,7 +25,7 @@ export const CodeViewer: FC = ({ code, language }) => { }; return ( -
+
{language}
- - {code} - + + + {code} + +
); }; diff --git a/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx b/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx index 09d0ce9536..796f853cdb 100644 --- a/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx +++ b/frontend/src/components/blocks/interview/question-attempts/attempt-details/main.tsx @@ -31,7 +31,7 @@ export const AttemptDetailsDialog: FC ) : ( {triggerText} )} - + Attempt {attemptId} diff --git a/frontend/src/components/blocks/interview/question-attempts/table.tsx b/frontend/src/components/blocks/interview/question-attempts/table.tsx index 893dce4f54..75fa505cf0 100644 --- a/frontend/src/components/blocks/interview/question-attempts/table.tsx +++ b/frontend/src/components/blocks/interview/question-attempts/table.tsx @@ -89,12 +89,6 @@ export function QuestionAttemptsTable({ noOptionsText='None of the available languages match your search' />
- {/* table.getColumn('title')?.setFilterValue(event.target.value)} - className='max-w-sm' - /> */}
diff --git a/frontend/src/components/blocks/interview/room/complete-dialog.tsx b/frontend/src/components/blocks/interview/room/complete-dialog.tsx index 331f84bcb3..6ddce09452 100644 --- a/frontend/src/components/blocks/interview/room/complete-dialog.tsx +++ b/frontend/src/components/blocks/interview/room/complete-dialog.tsx @@ -1,3 +1,4 @@ +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { useMutation } from '@tanstack/react-query'; import { Loader2 } from 'lucide-react'; import { Dispatch, FC, PropsWithChildren, SetStateAction, useCallback, useState } from 'react'; @@ -7,8 +8,10 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, + DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { addQuestionAttempt } from '@/services/question-service'; @@ -69,6 +72,10 @@ export const CompleteDialog: FC> = ({ // Navigate to home page setTimeout(() => { setCompleting(COMPLETION_STATES.EMPTY, true); + setIsOpen(false); + // Clear AI chat if moving away + localStorage.removeItem('ai-assist-lang'); + localStorage.removeItem('ai_chat_history'); navigate('/'); }, 200); }, @@ -81,9 +88,12 @@ export const CompleteDialog: FC> = ({ {children} - - Are you sure you wish to mark this question as complete? + + Are you sure you wish to mark this question as complete? + + +
) ) : ( - + <> + {data?.isAdmin && ( + + Admin + + )} + + )}
diff --git a/frontend/src/components/blocks/questions/admin-delete-form.tsx b/frontend/src/components/blocks/questions/admin-delete-form.tsx new file mode 100644 index 0000000000..1a25232fc4 --- /dev/null +++ b/frontend/src/components/blocks/questions/admin-delete-form.tsx @@ -0,0 +1,68 @@ +import { TrashIcon } from '@radix-ui/react-icons'; +import { useMutation } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { Dispatch, FC, SetStateAction } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { adminDeleteQuestion } from '@/services/question-service'; + +type AdminDeleteFormProps = { + isOpen: boolean; + setIsOpen: Dispatch>; + questionId: number; +}; + +export const AdminDeleteForm: FC = ({ isOpen, setIsOpen, questionId }) => { + const navigate = useNavigate(); + const { + mutate: deleteQuestion, + isPending, + isSuccess, + } = useMutation({ + mutationFn: (questionId: number) => adminDeleteQuestion(questionId), + onSuccess: () => { + setTimeout(() => { + navigate('/'); + }, 700); + }, + }); + + return ( + + + + + Are you sure you want to delete question: 
{questionId}
? +
+
+ + + + Cancel + + { + event.preventDefault(); + deleteQuestion(questionId); + }} + disabled={isPending || isSuccess} + className='flex items-center gap-2' + > + {isPending ? 'Deleting...' : isSuccess ? 'Deleted Successfully' : 'Delete'} + {isPending ? : } + + +
+
+ ); +}; diff --git a/frontend/src/components/blocks/questions/admin-edit-form.tsx b/frontend/src/components/blocks/questions/admin-edit-form.tsx new file mode 100644 index 0000000000..86b4ce9ccf --- /dev/null +++ b/frontend/src/components/blocks/questions/admin-edit-form.tsx @@ -0,0 +1,373 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { type Dispatch, type FC, type SetStateAction,useState } from 'react'; +import { useForm } from 'react-hook-form'; +import Markdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import rehypeKatex from 'rehype-katex'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import { z } from 'zod'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ComboboxExternal } from '@/components/ui/combobox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { getDifficultiesQueryConfig, getTopicsQueryConfig } from '@/routes/match/logic'; +import { adminAddQuestion, adminUpdateQuestion } from '@/services/question-service'; +import { useAuthedRoute } from '@/stores/auth-store'; +import type { IGetQuestionDetailsResponse } from '@/types/question-types'; + +type AdminEditFormProps = { + isFormOpen: boolean; + setIsFormOpen: Dispatch>; + questionDetails: IGetQuestionDetailsResponse['question']; + mode?: 'create' | 'update'; +}; + +const formSchema = z.object({ + title: z.string().min(1, 'Title must not be empty'), + difficulty: z.string().min(1, 'Choose a difficulty'), + topics: z + .string() + .min(1, 'Topic cannot be empty') + .array() + .min(1, 'There must be at least 1 topic'), + description: z.string().min(1, 'Description must not be empty'), +}); + +export const AdminEditForm: FC = ({ + questionDetails, + isFormOpen, + setIsFormOpen, + mode = 'update', +}) => { + const { userId } = useAuthedRoute(); + const { topic, ...rest } = questionDetails; + const [defaultValues, setDefaultValues] = useState({ ...rest, topics: topic }); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues, + mode: 'onSubmit', + }); + + const queryClient = useQueryClient(); + const { + mutate: sendUpdate, + isPending, + isSuccess, + reset, + } = useMutation({ + mutationKey: [questionDetails], + mutationFn: (values: z.infer) => { + return mode === 'update' + ? adminUpdateQuestion({ ...values, questionId: Number.parseInt(questionDetails.id!) }) + : adminAddQuestion(values); + }, + onSuccess: (_response, variables, _context) => { + if (mode === 'update') { + queryClient.refetchQueries({ + queryKey: ['qn', 'details', Number.parseInt(questionDetails.id!)], + }); + } else { + queryClient.refetchQueries({ + queryKey: ['questions', userId], + }); + } + + setTimeout(() => { + reset(); + + if (mode === 'update') { + setDefaultValues(variables); + form.reset(variables); + } else { + form.reset(); + } + + setIsFormOpen(false); + }, 500); + }, + }); + + const topicsQuery = useQuery(getTopicsQueryConfig()); + const difficultiesQuery = useQuery(getDifficultiesQueryConfig()); + + const onSubmit = (formValues: z.infer) => { + const parsed = formSchema.safeParse(formValues); + + if (parsed.success) { + sendUpdate(parsed.data); + } + }; + + return ( + <> + { + if (!isOpen) { + form.reset(); + } + + setIsFormOpen(isOpen); + }} + > + + + + {mode === 'update' ? 'Edit Question Details' : 'Add a question'} + + +
+ + ( + + Title + + + + + + )} + /> + ( + + Difficulty + + + + + + )} + /> + ( + +
+ Topics +
+ +
+
+ ({ + value: v, + label: v.replace(/^[a-zA-Z]/, (c) => c.toUpperCase()), + }))} + chosenOptions={field.value} + setChosenOptions={field.onChange} + /> + +
+
+
+
+ {field.value.map((value, index) => ( + + {value} + { + if (isPending) { + return; + } + + form.setValue( + 'topics', + field.value.filter((_value, idx) => idx !== index) + ); + }} + /> + + ))} +
+ +
+ )} + /> + ( + + + + Description + Preview + + + +