From 959d3cbe827e6d74a34f14ad490e75409db1b565 Mon Sep 17 00:00:00 2001 From: ImJustChew Date: Thu, 1 Aug 2024 14:57:34 +0800 Subject: [PATCH] feat: added update password --- src/app/[lang]/(mods-pages)/settings/page.tsx | 8 +- src/components/Forms/ChangePasswordDialog.tsx | 109 ++++++++++++++++ src/components/Forms/UpdatePasswordDialog.tsx | 116 ++++++++++++++++++ src/hooks/contexts/useHeadlessAIS.tsx | 36 ++++-- src/lib/headless_ais.ts | 46 +++++-- src/types/headless_ais.ts | 9 +- 6 files changed, 301 insertions(+), 23 deletions(-) create mode 100644 src/components/Forms/ChangePasswordDialog.tsx create mode 100644 src/components/Forms/UpdatePasswordDialog.tsx diff --git a/src/app/[lang]/(mods-pages)/settings/page.tsx b/src/app/[lang]/(mods-pages)/settings/page.tsx index f8cddd22..eacd5c1d 100644 --- a/src/app/[lang]/(mods-pages)/settings/page.tsx +++ b/src/app/[lang]/(mods-pages)/settings/page.tsx @@ -26,6 +26,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import {GraduationCap, Hash} from 'lucide-react'; +import ChangePasswordDialog from "@/components/Forms/ChangePasswordDialog"; const DisplaySettingsCard = () => { const { darkMode, setDarkMode, language, setLanguage } = useSettings(); @@ -86,6 +87,8 @@ const TimetableSettingsCard = () => { const AccountInfoSettingsCard = () => { const { user, ais, setAISCredentials } = useHeadlessAIS(); const dict = useDictionary(); + const [openChangePassword, setOpenChangePassword] = useState(false); + return {dict.settings.account.title} @@ -103,7 +106,10 @@ const AccountInfoSettingsCard = () => {
{user.studentid}
-
+
+ + +
} diff --git a/src/components/Forms/ChangePasswordDialog.tsx b/src/components/Forms/ChangePasswordDialog.tsx new file mode 100644 index 00000000..b6a2062c --- /dev/null +++ b/src/components/Forms/ChangePasswordDialog.tsx @@ -0,0 +1,109 @@ +"use client"; +import { + Dialog, + DialogClose, + 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"; +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 ChangePasswordDialog = ({ open, setOpen, children }: PropsWithChildren<{ open: boolean, setOpen: (s: boolean) => void }>) => { + const { setAISCredentials, user } = useHeadlessAIS(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + newPassword: "", + }, + }) + + async function onSubmit(values: z.infer) { + if (!user) return; + await setAISCredentials(user.studentid, values.newPassword) + setOpen(false); + toast({ + title: "密碼更新成功", + }) + } + + return ( + + {children} + + + 更新密碼 + 同學~你好像在校務資訊系統有跟新密碼哦,請也在這邊跟新! + +
+ + ( + + New Password + + + + + + )} + /> + + + + + + + 確定要登出嗎? + 登出後將無法使用校務資訊系統相關功能,確定要登出嗎? + + + + + + + + + + + + +
+
+ + ) +} + +export default ChangePasswordDialog; \ No newline at end of file diff --git a/src/components/Forms/UpdatePasswordDialog.tsx b/src/components/Forms/UpdatePasswordDialog.tsx new file mode 100644 index 00000000..2c37db7d --- /dev/null +++ b/src/components/Forms/UpdatePasswordDialog.tsx @@ -0,0 +1,116 @@ +"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; \ No newline at end of file diff --git a/src/hooks/contexts/useHeadlessAIS.tsx b/src/hooks/contexts/useHeadlessAIS.tsx index bbad83af..f3fb1eef 100644 --- a/src/hooks/contexts/useHeadlessAIS.tsx +++ b/src/hooks/contexts/useHeadlessAIS.tsx @@ -7,6 +7,7 @@ import useDictionary from "@/dictionaries/useDictionary"; import { useCookies } from "react-cookie"; import { decodeJwt } from 'jose'; import { refreshUserSession, signInToCCXP } from '@/lib/headless_ais'; +import dynamic from 'next/dynamic'; const headlessAISContext = createContext>({ user: undefined, ais: { @@ -18,9 +19,11 @@ const headlessAISContext = createContext false, getACIXSTORE: async () => undefined, + openChangePassword: false, + setOpenChangePassword: () => {} }); - +const ChangePasswordDialogDynamic = dynamic(() => import('@/components/Forms/ChangePasswordDialog'), { ssr: false }); const useHeadlessAISProvider = () => { const [headlessAIS, setHeadlessAIS] = useLocalStorage("headless_ais", { enabled: false }); @@ -29,6 +32,7 @@ const useHeadlessAISProvider = () => { const [error, setError] = useState(undefined); const [cookies, setCookies, removeCookies, updateCookies] = useCookies(['accessToken']); const dict = useDictionary(); + const [openChangePassword, setOpenChangePassword] = useState(false); useEffect(() => { setInitializing(false) }, []); @@ -66,7 +70,12 @@ const useHeadlessAISProvider = () => { password: res.encryptedPassword, encrypted: true, ACIXSTORE: res.ACIXSTORE, - lastUpdated: Date.now() + lastUpdated: Date.now(), + expired: res.passwordExpired + }); + if(res.passwordExpired) toast({ + title: "提醒您校務系統密碼已經過期~ ", + description: "但是NTHUMods 的功能都不會被影響 ヾ(≧▽≦*)o", }); setLoading(false); setError(undefined); @@ -77,9 +86,6 @@ const useHeadlessAISProvider = () => { title: "代理登入失敗", description: dict.ccxp.errors[err.message as keyof typeof dict.ccxp.errors] ?? "目前認證服务降级,请稍后再试,敬請見諒", }) - setHeadlessAIS({ - enabled: false - }); setLoading(false); setError(err); return false; @@ -117,7 +123,12 @@ const useHeadlessAISProvider = () => { password: res.encryptedPassword, encrypted: true, ACIXSTORE: res.ACIXSTORE, - lastUpdated: Date.now() + lastUpdated: Date.now(), + expired: res.passwordExpired + }); + if(res.passwordExpired) toast({ + title: "提醒您校務系統密碼已經過期~ ", + description: "但是NTHUMods 的功能都不會被影響 ヾ(≧▽≦*)o", }); setLoading(false); setError(undefined); @@ -134,13 +145,21 @@ const useHeadlessAISProvider = () => { password: headlessAIS.password, encrypted: true, ACIXSTORE: res.ACIXSTORE, - lastUpdated: Date.now() + lastUpdated: Date.now(), + expired: res.passwordExpired + }); + if(res.passwordExpired) toast({ + title: "提醒您校務系統密碼已經過期~ ", + description: "但是NTHUMods 的功能都不會被影響 ヾ(≧▽≦*)o", }); setLoading(false); setError(undefined); return res.ACIXSTORE; }) .catch(err => { + if(err.message == LoginError.IncorrectCredentials) { + setOpenChangePassword(true); + } toast({ title: "代理登入失敗", description: dict.ccxp.errors[err.message as keyof typeof dict.ccxp.errors] ?? "目前認證服务降级,请稍后再试,敬請見諒", @@ -166,6 +185,8 @@ const useHeadlessAISProvider = () => { setAISCredentials, getACIXSTORE, initializing, + openChangePassword, + setOpenChangePassword }; } @@ -178,6 +199,7 @@ const HeadlessAISProvider: FC = ({ children }) => { return ( {children} + ); }; diff --git a/src/lib/headless_ais.ts b/src/lib/headless_ais.ts index c0db47e1..edf45d3c 100644 --- a/src/lib/headless_ais.ts +++ b/src/lib/headless_ais.ts @@ -102,19 +102,19 @@ async function streamAndMatch(response: Response, regex: RegExp) { throw new Error(LoginError.Unknown); } -type SignInToCCXPResponse = Promise<{ ACIXSTORE: string, encryptedPassword: string } | { error: { message: string } }>; +type SignInToCCXPResponse = Promise<{ ACIXSTORE: string, encryptedPassword: string, passwordExpired: boolean } | { error: { message: string } }>; /** * Attempts to login user to CCXP, takes in raw studentid and password * ONLY use this for first time login, will return encrypted password and ACIXSTORE * @param studentid * @param password - * @returns { ACIXSTORE: string, encryptedPassword: string } + * @returns { ACIXSTORE: string, encryptedPassword: string, passwordExpired: boolean } */ export const signInToCCXP = async (studentid: string, password: string): SignInToCCXPResponse => { console.log("Signing in to CCXP") let startTime = Date.now(); try { - const ocrAndLogin: (_try?:number) => Promise<{ ACIXSTORE: string }> = async (_try = 0) => { + const ocrAndLogin: (_try?:number) => Promise<{ ACIXSTORE: string, passwordExpired: boolean }> = async (_try = 0) => { if(_try == 3) { throw new Error(LoginError.Unknown); } @@ -220,6 +220,8 @@ export const signInToCCXP = async (studentid: string, password: string): SignInT }) console.log('Time taken', Date.now() - startTime); startTime = Date.now(); + + const passwordExpired = !!newHTML.match('個人密碼修改'); if(resHTML.match('驗證碼輸入錯誤!')) { return await ocrAndLogin(_try++); } @@ -238,7 +240,7 @@ export const signInToCCXP = async (studentid: string, password: string): SignInT if(!ACIXSTORE) { return await ocrAndLogin(_try++); } - return { ACIXSTORE }; + return { ACIXSTORE, passwordExpired }; } } const result = await ocrAndLogin(); @@ -258,7 +260,6 @@ export const signInToCCXP = async (studentid: string, password: string): SignInT "sec-fetch-user": "?1", "upgrade-insecure-requests": "1" }, - keepalive: true, "body": null, "method": "GET", "mode": "cors", @@ -315,7 +316,7 @@ export const signInToCCXP = async (studentid: string, password: string): SignInT } } -type RefreshUserSessionResponse = Promise<{ ACIXSTORE: string } | { error: { message: string } }>; +type RefreshUserSessionResponse = Promise<{ ACIXSTORE: string, passwordExpired: boolean } | { error: { message: string } }>; export const refreshUserSession = async (studentid: string, encryptedPassword: string): RefreshUserSessionResponse => { console.log('Refreshing User Session') // Decrypt password @@ -327,7 +328,38 @@ export const refreshUserSession = async (studentid: string, encryptedPassword: s return { error: res.error } } // @ts-ignore - We know that res is not an error - return { ACIXSTORE: res.ACIXSTORE }; + return { ACIXSTORE: res.ACIXSTORE, passwordExpired: res.passwordExpired }; +} + +export const updateUserPassword = async (ACIXSTORE: string, oldEncryptedPassword: 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" + }, + "referrer": "https://www.ccxp.nthu.edu.tw/ccxp/INQUIRE/PC/1/1.1/PC11001.php?ACIXSTORE=c3d1ipem8trmvk6gpq5mrv9490", + "referrerPolicy": "strict-origin-when-cross-origin", + "body": `ACIXSTORE=${ACIXSTORE}&O_PASS=${oldPassword}&N_PASS=${newPassword}&N_PASS2=${newPassword}&choice=確定`, + "method": "POST", + "mode": "cors", + "credentials": "include" + }); + + // Encrypt new password + const newEncryptedPassword = await encrypt(newPassword); + return newEncryptedPassword; } export const getUserSession = async () => { diff --git a/src/types/headless_ais.ts b/src/types/headless_ais.ts index 49bcb2af..59c23588 100644 --- a/src/types/headless_ais.ts +++ b/src/types/headless_ais.ts @@ -1,11 +1,3 @@ -import { cookies } from "next/headers"; - -export const getServerACIXSTORE = async () => { - const cookie = await cookies(); - const ACIXSTORE = cookie.get('ACIXSTORE')?.value; - return ACIXSTORE; -} - export type HeadlessAISStorage = { enabled: false } | { enabled: true, studentid: string, @@ -13,6 +5,7 @@ export type HeadlessAISStorage = { enabled: false } | { encrypted: boolean, ACIXSTORE?: string, lastUpdated: number, + expired: boolean } export enum LoginError {