diff --git a/src/app/[lang]/(mods-pages)/settings/page.tsx b/src/app/[lang]/(mods-pages)/settings/page.tsx index ed29b139..f3822633 100644 --- a/src/app/[lang]/(mods-pages)/settings/page.tsx +++ b/src/app/[lang]/(mods-pages)/settings/page.tsx @@ -29,7 +29,6 @@ import useUserTimetable from "@/hooks/contexts/useUserTimetable"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { GraduationCap, Hash } from "lucide-react"; -import ChangePasswordDialog from "@/components/Forms/ChangePasswordDialog"; import { useAuthState } from "react-firebase-hooks/auth"; import { auth } from "@/config/firebase"; import { @@ -45,6 +44,7 @@ import { import { useLocalStorage } from "usehooks-ts"; import { Badge } from "@/components/ui/badge"; import { event } from "@/lib/gtag"; +import ChangePasswordDialog from "@/components/Forms/ChangePasswordDialog"; const DisplaySettingsCard = () => { const { darkMode, setDarkMode, language, setLanguage } = useSettings(); @@ -183,7 +183,6 @@ const CalendarSettingsCard = () => { const AccountInfoSettingsCard = () => { const { user, ais, signOut, getACIXSTORE } = useHeadlessAIS(); const dict = useDictionary(); - const [openChangePassword, setOpenChangePassword] = useState(false); return ( @@ -209,12 +208,9 @@ const AccountInfoSettingsCard = () => {
- + {/* - + */} - - - + diff --git a/src/components/Forms/RenewPasswordDialog.tsx b/src/components/Forms/RenewPasswordDialog.tsx new file mode 100644 index 00000000..f1d29dbc --- /dev/null +++ b/src/components/Forms/RenewPasswordDialog.tsx @@ -0,0 +1,130 @@ +"use client"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import { z } from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { PropsWithChildren } from "react"; +import { useHeadlessAIS } from "@/hooks/contexts/useHeadlessAIS"; +import { Loader2 } from "lucide-react"; +import { toast } from "../ui/use-toast"; + +// * 8~16字元 +// * 至少包含1大寫英文字母 +// * 至少包含1小寫英文字母 +// * 至少包含1個數字 +// * 密碼三代不重覆 +const formSchema = z.object({ + newPassword: z + .string() + .min(8) + .max(16) + .refine( + (value) => { + const hasLowerCase = /[a-z]/.test(value); + const hasUpperCase = /[A-Z]/.test(value); + const hasNumber = /[0-9]/.test(value); + return hasLowerCase && hasUpperCase && hasNumber; + }, + { + message: + "Password must contain at least 1 uppercase letter, 1 lowercase letter, and 1 number", + }, + ), +}); + +const RenewPasswordDialog = ({ + open, + setOpen, + children, +}: PropsWithChildren<{ open: boolean; setOpen: (s: boolean) => void }>) => { + const { signIn, signOut, user, isACIXSTOREValid } = useHeadlessAIS(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + newPassword: "", + }, + }); + + async function onSubmit(values: z.infer) { + if (!user) return; + const success = await signIn(user.studentid, values.newPassword); + if (!success) { + setOpen(false); + toast({ + title: "密碼更新成功", + }); + } else { + toast({ + title: "密碼更新失敗", + }); + } + } + + return ( + + + {children} + + + + 更新密碼 + + 同學~你好像在校務資訊系統有跟新密碼哦,請也在這邊跟新! + + +
+ + ( + + New Password + + + + + + )} + /> + + + + + + + + +
+
+ ); +}; + +export default RenewPasswordDialog; diff --git a/src/components/Forms/UpdatePasswordDialog.tsx b/src/components/Forms/UpdatePasswordDialog.tsx deleted file mode 100644 index eac3b21d..00000000 --- a/src/components/Forms/UpdatePasswordDialog.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -import { z } from "zod"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "../ui/form"; -import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; - -// * 8~16字元 -// * 至少包含1大寫英文字母 -// * 至少包含1小寫英文字母 -// * 至少包含1個數字 -// * 密碼三代不重覆 -const formSchema = z - .object({ - oldPassword: z.string().min(8), - newPassword: z - .string() - .min(8) - .max(16) - .refine( - (value) => { - const hasLowerCase = /[a-z]/.test(value); - const hasUpperCase = /[A-Z]/.test(value); - const hasNumber = /[0-9]/.test(value); - return hasLowerCase && hasUpperCase && hasNumber; - }, - { - message: - "Password must contain at least 1 uppercase letter, 1 lowercase letter, and 1 number", - }, - ), - confirmPassword: z.string().min(8).max(16), - }) - .refine((data) => data.newPassword === data.confirmPassword, { - message: "Passwords do not match", - }); - -const UpdatePasswordDialog = () => { - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - oldPassword: "", - newPassword: "", - confirmPassword: "", - }, - }); - - function onSubmit(values: z.infer) { - // Do something with the form values. - // ✅ This will be type-safe and validated. - console.log(values); - } - - return ( - - Update Password - - - Password Update - - Due to the perfect security of changing your password, we will help - you to update your password, while logged in. - - -
- - ( - - Old Password - - - - - - )} - /> - ( - - New Password - - - - - - )} - /> - ( - - Confirm Password - - - - - - )} - /> - - - -
-
- ); -}; - -export default UpdatePasswordDialog; diff --git a/src/hooks/contexts/useHeadlessAIS.tsx b/src/hooks/contexts/useHeadlessAIS.tsx index 37edb776..e094141e 100644 --- a/src/hooks/contexts/useHeadlessAIS.tsx +++ b/src/hooks/contexts/useHeadlessAIS.tsx @@ -38,6 +38,7 @@ const headlessAISContext = createContext< signIn: async () => false, signOut: async () => {}, getACIXSTORE: async () => undefined, + isACIXSTOREValid: false, openChangePassword: false, setOpenChangePassword: () => {}, }); @@ -230,6 +231,22 @@ const useHeadlessAISProvider = () => { enabled: headlessAIS.enabled, }; + const [isACIXSTOREValid, setIsACIXSTOREValid] = useState(false); + + // check every second + useEffect(() => { + const interval = setInterval(() => { + if (headlessAIS.enabled) { + setIsACIXSTOREValid( + headlessAIS.lastUpdated + 15 * 60 * 1000 > Date.now(), + ); + } else { + setIsACIXSTOREValid(false); + } + }, 1000); + return () => clearInterval(interval); + }, [headlessAIS]); + return { user, ais, @@ -238,6 +255,7 @@ const useHeadlessAISProvider = () => { signIn, signOut, getACIXSTORE, + isACIXSTOREValid, initializing, openChangePassword, setOpenChangePassword, diff --git a/src/lib/headless_ais.ts b/src/lib/headless_ais.ts index 9da0b5a1..c1ea37e3 100644 --- a/src/lib/headless_ais.ts +++ b/src/lib/headless_ais.ts @@ -416,35 +416,55 @@ export const refreshUserSession = async ( export const updateUserPassword = async ( ACIXSTORE: string, - oldEncryptedPassword: string, + oldPassword: string, newPassword: string, ) => { - // Decrypt old password - const oldPassword = await decrypt(oldEncryptedPassword); - fetch("https://www.ccxp.nthu.edu.tw/ccxp/INQUIRE/PC/1/1.1/PC11002.php", { - headers: { - accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", - "accept-language": "en-US,en;q=0.9", - "cache-control": "max-age=0", - "content-type": "application/x-www-form-urlencoded", - "sec-ch-ua": - '"Not)A;Brand";v="99", "Microsoft Edge";v="127", "Chromium";v="127"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-fetch-dest": "frame", - "sec-fetch-mode": "navigate", - "sec-fetch-site": "same-origin", - "sec-fetch-user": "?1", - "upgrade-insecure-requests": "1", - }, - body: `ACIXSTORE=${ACIXSTORE}&O_PASS=${encodeURIComponent(oldPassword)}&N_PASS=${encodeURIComponent(newPassword)}&N_PASS2=${encodeURIComponent(newPassword)}&choice=確定`, - method: "POST", - mode: "cors", - credentials: "include", - }); - - // Encrypt new password - const newEncryptedPassword = await encrypt(newPassword); - return newEncryptedPassword; + try { + // Decrypt old password + const res = await fetch( + "https://www.ccxp.nthu.edu.tw/ccxp/INQUIRE/PC/1/1.1/PC11002.php", + { + headers: { + accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "accept-language": "en-US,en;q=0.9", + "cache-control": "max-age=0", + "content-type": "application/x-www-form-urlencoded", + "sec-ch-ua": + '"Chromium";v="128", "Not;A=Brand";v="24", "Microsoft Edge";v="128"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "frame", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + }, + referrer: + "https://www.ccxp.nthu.edu.tw/ccxp/INQUIRE/PC/1/1.1/PC11001.php", + referrerPolicy: "strict-origin-when-cross-origin", + body: `ACIXSTORE=${ACIXSTORE}&O_PASS=${encodeURIComponent(oldPassword)}&N_PASS=${encodeURIComponent(newPassword)}&N_PASS2=${encodeURIComponent(newPassword)}&choice=確定`, + method: "POST", + mode: "cors", + credentials: "include", + }, + ); + + if (!res) { + throw new Error("Sync Failed!"); + } + + // check the return html for the text alert('上次密碼修改時間是'.20240916 23:38:05.', 24小時內不能再次修改密碼!'); + const text = await res.text(); + if (text.includes("上次密碼修改時間是")) { + throw new Error("Password can only be changed once every 24 hours."); + } + return true; + } catch (e) { + if (e instanceof Error) { + return { + error: { message: e.message }, + }; + } + } };