diff --git a/apps/frontend/app/(main)/settings/page.tsx b/apps/frontend/app/(main)/settings/page.tsx new file mode 100644 index 0000000000..fa7821ab7b --- /dev/null +++ b/apps/frontend/app/(main)/settings/page.tsx @@ -0,0 +1,559 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { majors } from '@/lib/constants' +import { cn, safeFetcher, safeFetcherWithAuth } from '@/lib/utils' +import invisible from '@/public/24_invisible.svg' +import visible from '@/public/24_visible.svg' +import codedangSymbol from '@/public/codedang-editor.svg' +import { zodResolver } from '@hookform/resolvers/zod' +import type { NavigateOptions } from 'next/dist/shared/lib/app-router-context.shared-runtime' +import Image from 'next/image' +import { useRouter } from 'next/navigation' +import React, { useEffect } from 'react' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { FaCheck, FaChevronDown } from 'react-icons/fa6' +import { toast } from 'sonner' +import { z } from 'zod' + +interface SettingsFormat { + currentPassword: string + newPassword: string + confirmPassword: string + realName: string + studentId: string +} + +interface getProfile { + username: string // ID + userProfile: { + realName: string + } + studentId: string + major: string +} + +type UpdatePayload = Partial<{ + password: string + newPassword: string + realName: string + studentId: string + major: string +}> + +// const schemaUpdate = z.object({ +// studentId: z.string().optional(), +// password: z.string().optional(), +// newPassword: z.string().optional(), +// realName: z.string().optional(), +// major: z.string().optional() +// }) + +const schemaSettings = z.object({ + currentPassword: z.string().min(1, { message: 'Required' }).optional(), + newPassword: z + .string() + .min(1) + .min(8) + .max(20) + .refine((data) => { + const invalidPassword = /^([a-z]*|[A-Z]*|[0-9]*|[^a-zA-Z0-9]*)$/ + return !invalidPassword.test(data) + }) + .optional(), + confirmPassword: z.string().optional(), + realName: z + .string() + .regex(/^[a-zA-Z\s]+$/, { message: 'Only English Allowed' }) + .optional(), + studentId: z.string().optional() +}) + +function requiredMessage(message?: string) { + return ( +
+ {message} +
+ ) +} + +export default function Page() { + const [defaultProfileValues, setdefaultProfileValues] = useState({ + username: '', + userProfile: { + realName: '' + }, + studentId: '', + major: '' + }) + + useEffect(() => { + const fetchDefaultProfile = async () => { + try { + const data: getProfile = await safeFetcherWithAuth.get('user').json() + setMajorValue(data.major) + setdefaultProfileValues(data) + setIsLoading(false) + } catch (error) { + console.error('Failed to fetch profile:', error) + toast.error('Failed to load profile data') + setIsLoading(false) + } + } + fetchDefaultProfile() + }, []) + + const { + register, + handleSubmit, + getValues, + setValue, + watch, + formState: { errors, isDirty } + } = useForm({ + resolver: zodResolver(schemaSettings), + mode: 'onChange', + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '', + realName: defaultProfileValues.userProfile.realName, + studentId: defaultProfileValues.studentId + } + }) + + // const beforeUnloadHandler = (event: BeforeUnloadEvent) => { + // // Recommended + // event.preventDefault() + + // // Included for legacy support, e.g. Chrome/Edge < 119 + // event.returnValue = true + // return true + // } + + /** + * Prompt the user with a confirmation dialog when they try to navigate away from the page. + */ + const useConfirmNavigation = () => { + const router = useRouter() + useEffect(() => { + const originalPush = router.push + const newPush = ( + href: string, + options?: NavigateOptions | undefined + ): void => { + const isConfirmed = window.confirm( + 'Are you sure you want to leave?\nYour changes have not been saved.\nIf you leave this page, all changes will be lost.\nDo you still want to proceed?' + ) + // Error occurs if I just put 'href' without 'href === ...' code.. + if (isConfirmed) { + if ( + href === '/settings' || + href === '/notice' || + href === '/problem' || + href === '/contest' || + href === '/' + ) { + originalPush(href, options) + } + } + } + router.push = newPush + // window.onbeforeunload = beforeUnloadHandler + return () => { + router.push = originalPush + // window.onbeforeunload = null + } + }, [router, isDirty]) + } + + useConfirmNavigation() + + const [isCheckButtonClicked, setIsCheckButtonClicked] = + useState(false) + const [isPasswordCorrect, setIsPasswordCorrect] = useState(false) + const [newPasswordAble, setNewPasswordAble] = useState(false) + const [passwordShow, setPasswordShow] = useState(false) + const [newPasswordShow, setNewPasswordShow] = useState(false) + const [confirmPasswordShow, setConfirmPasswordShow] = useState(false) + const [majorOpen, setMajorOpen] = useState(false) + const [majorValue, setMajorValue] = useState('') + const currentPassword = watch('currentPassword') + const newPassword = watch('newPassword') + const confirmPassword = watch('confirmPassword') + const realName = watch('realName') + const isPasswordsMatch = newPassword === confirmPassword && newPassword !== '' + const [isLoading, setIsLoading] = useState(true) + // saveAble1, saveAble2 둘 중 하나라도 true 면 Save 버튼 활성화 + const saveAblePassword: boolean = + !!currentPassword && + !!newPassword && + !!confirmPassword && + isPasswordCorrect && + newPasswordAble && + isPasswordsMatch + const saveAbleOthers: boolean = + !!realName || !!(majorValue !== defaultProfileValues.major) + const saveAble = saveAblePassword || saveAbleOthers + + // New Password Input 창과 Re-enter Password Input 창의 border 색상을, 일치하는지 여부에 따라 바꿈 + useEffect(() => { + if (isPasswordsMatch) { + setValue('newPassword', newPassword) + setValue('confirmPassword', confirmPassword) + } + }, [isPasswordsMatch, newPassword, confirmPassword]) + + const onSubmit = async (data: SettingsFormat) => { + try { + // 필요 없는 필드 제외 (defaultProfileValues와 값이 같은 것들은 제외) + const updatePayload: UpdatePayload = {} + if (data.realName !== defaultProfileValues.userProfile.realName) { + updatePayload.realName = data.realName + } + if (majorValue !== defaultProfileValues.major) { + updatePayload.major = majorValue + } + if (data.currentPassword !== 'tmppassword1') { + updatePayload.password = data.currentPassword + } + if (data.newPassword !== 'tmppassword1') { + updatePayload.newPassword = data.newPassword + } + + const response = await safeFetcherWithAuth.patch('user', { + json: updatePayload + }) + if (response.ok) { + toast.success('Successfully updated your information') + setTimeout(() => { + window.location.reload() + }, 1500) + } + } catch (error) { + console.error(error) + toast.error('Failed to update your information, Please try again') + setTimeout(() => { + window.location.reload() + }, 1500) + } + } + + const onSubmitClick = () => { + return () => { + // submit 되기위해, watch로 확인되는 값이 default값과 같으면 setValue를 통해서 defaultProfileValues로 변경 + if (realName === '') { + setValue('realName', defaultProfileValues.userProfile.realName) + } + if (majorValue === defaultProfileValues.major) { + setMajorValue(defaultProfileValues.major) + } + if (currentPassword === '') { + setValue('currentPassword', 'tmppassword1') + } + if (newPassword === '') { + setValue('newPassword', 'tmppassword1') + } + if (confirmPassword === '') { + setValue('confirmPassword', 'tmppassword1') + } + } + } + + const checkPassword = async () => { + setIsCheckButtonClicked(true) + try { + const response = await safeFetcher.post('auth/login', { + json: { + username: defaultProfileValues.username, + password: currentPassword + } + }) + + if (response.status === 201) { + setIsPasswordCorrect(true) + setNewPasswordAble(true) + } + } catch { + console.error('Failed to check password') + } + } + + return ( +
+
+
+ codedang +

CODEDANG

+
+

Online Judge Platform for SKKU

+
+ +
+

Settings

+

+ You can change your information +

+ + {/* ID */} + + + + {/* Current password */} + +
+
+ + setPasswordShow(!passwordShow)} + > + {passwordShow ? ( + visible + ) : ( + invisible + )} + +
+ +
+ {errors.currentPassword && + errors.currentPassword.message === 'Required' && + requiredMessage('Required')} + {!errors.currentPassword && + isCheckButtonClicked && + (isPasswordCorrect ? ( +
+ Correct +
+ ) : ( +
+ Incorrect +
+ ))} + + {/* New password */} +
+
+ + setNewPasswordShow(!newPasswordShow)} + > + {newPasswordShow ? ( + visible + ) : ( + invisible + )} + +
+
+ {errors.newPassword && ( +
+
    +
  • 8-20 characters
  • +
  • + Include two of the following: capital letters, small letters, + numbers +
  • +
+
+ )} + + {/* Re-enter new password */} +
+
+ { + if (watch('newPassword') != val) { + return 'Incorrect' + } + } + })} + className={`flex justify-stretch border-neutral-300 ring-0 placeholder:text-neutral-400 focus-visible:ring-0 disabled:bg-neutral-200 ${ + isPasswordsMatch + ? 'border-primary' + : confirmPassword && 'border-red-500' + } `} + /> + setConfirmPasswordShow(!confirmPasswordShow)} + > + {confirmPasswordShow ? ( + visible + ) : ( + invisible + )} + +
+
+ {getValues('confirmPassword') && + (isPasswordsMatch ? ( +
+ Correct +
+ ) : ( +
+ Incorrect +
+ ))} + +
+ + {/* Name */} + + + {realName && + errors.realName && + requiredMessage(errors.realName.message)} + + {/* Student ID */} + + + + {/* First Major */} + +
+ + + + + + + + + No major found. + + + {majors?.map((major) => ( + { + setMajorValue(currentValue) + setMajorOpen(false) + }} + > + + {major} + + ))} + + + + + + +
+ + {/* Save Button */} +
+ +
+
+
+ ) +} diff --git a/apps/frontend/components/auth/HeaderAuthPanel.tsx b/apps/frontend/components/auth/HeaderAuthPanel.tsx index ba12eb0395..3efe1bc4a7 100644 --- a/apps/frontend/components/auth/HeaderAuthPanel.tsx +++ b/apps/frontend/components/auth/HeaderAuthPanel.tsx @@ -82,6 +82,18 @@ export default function HeaderAuthPanel({ )} + + + Settings + +