diff --git a/web/README.md b/web/README.md index 7cb7635..3dfe94e 100644 --- a/web/README.md +++ b/web/README.md @@ -8,13 +8,13 @@ Frontend Console สำหรับการจัดการ **AI Model Regist ## 💡 สถาปัตยกรรมและเทคโนโลยีหลัก -| องค์ประกอบ | เทคโนโลยี | บทบาท / เหตุผล | -| :--- | :--- | :--- | -| **Framework** | 🧩 **React (Vite)** | ความเร็วสูง, Hot Reload, โครงสร้าง Component-Based | -| **Styling** | 🎨 **Tailwind CSS + DaisyUI** | Utility-first CSS พร้อมชุด Component ที่สวยงาม | -| **Server State / Data Fetching** | ⚙️ **TanStack Query (React Query)** | จัดการ Cache, Loading, Error, และ Data Synchronization | -| **Form Handling** | 🧠 **React Hook Form + Yup** | Validation ที่มีประสิทธิภาพ, ลดการ re-render | -| **Security Flow** | 🔐 **JWT (Bearer/Interceptor)** | ใช้ `axiosClient` จัดการ Token, Refresh Token, และ Force Logout | +| องค์ประกอบ | เทคโนโลยี | บทบาท / เหตุผล | +| :--- |:-----------------------------------------------| :--- | +| **Framework** | 🧩 **React (Vite)** | ความเร็วสูง, Hot Reload, โครงสร้าง Component-Based | +| **Styling** | 🎨 **Tailwind CSS + DaisyUI** | Utility-first CSS พร้อมชุด Component ที่สวยงาม | +| **Server State / Data Fetching** | ⚙️ **TanStack Query (React Query)** | จัดการ Cache, Loading, Error, และ Data Synchronization | +| **Form Handling** | 🧠 **React Hook Form + Yup** | Validation ที่มีประสิทธิภาพ, ลดการ re-render | +| **Security Flow** | 🔐 **JWT + Axios Interceptor + Refresh Token** | ใช้ `axiosClient` จัดการ Token, Refresh Token, และ Force Logout | --- diff --git a/web/src/components/Profile/PasswordChangeForm.jsx b/web/src/components/Profile/PasswordChangeForm.jsx new file mode 100644 index 0000000..f133a65 --- /dev/null +++ b/web/src/components/Profile/PasswordChangeForm.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { passwordSchema } from '../../schemas/authSchema'; +import InputText from '../InputText'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axiosClient from '../../services/axiosClient'; +import { useDispatch } from 'react-redux'; +import { addToast } from '../../features/toast/toastSlice'; +import ErrorText from '../ErrorText'; + + +// Hook เฉพาะสำหรับการเปลี่ยนรหัสผ่าน (POST /users/set_password/) +const useChangePasswordMutation = () => { + const dispatch = useDispatch(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ current_password, new_password, re_new_password }) => { + // Djoser Endpoint + const response = await axiosClient.post(`/api/v1/auth/users/set_password/`, { + current_password: current_password, + new_password: new_password, + re_new_password: re_new_password, // Djoser ต้องการ re_new_password field + }); + return response.data; + }, + onSuccess: () => { + dispatch(addToast({ message: 'เปลี่ยนรหัสผ่านสำเร็จแล้ว!', type: 'success' })); + queryClient.invalidateQueries({ queryKey: ['userProfile'] }); // Invalidate เพื่อความปลอดภัย + }, + onError: (error) => { + const msg = error.response?.data?.current_password?.[0] || 'รหัสผ่านปัจจุบันไม่ถูกต้อง'; + dispatch(addToast({ message: `เปลี่ยนรหัสผ่านล้มเหลว: ${msg}`, type: 'error' })); + throw new Error(msg); // Throw เพื่อให้ RHF จับ error ได้ + }, + }); +}; + + +export default function PasswordChangeForm() { + const changeMutation = useChangePasswordMutation(); + const isChanging = changeMutation.isPending; + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: yupResolver(passwordSchema), + }); + + const onSubmit = (data) => { + changeMutation.mutate(data, { + onSuccess: () => { + reset(); // Clear form on success + } + }); + }; + + return ( +
+ + + + + {changeMutation.isError && ( + + {errors.current_password?.message || changeMutation.error.message || 'เกิดข้อผิดพลาดในการเปลี่ยนรหัสผ่าน'} + + )} + + + + ); +} \ No newline at end of file diff --git a/web/src/components/Profile/ProfileEditForm.jsx b/web/src/components/Profile/ProfileEditForm.jsx new file mode 100644 index 0000000..ad5afab --- /dev/null +++ b/web/src/components/Profile/ProfileEditForm.jsx @@ -0,0 +1,52 @@ +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { profileSchema } from '../../schemas/authSchema'; +import InputText from '../InputText'; +import { useUpdateProfileMutation } from '../../services/authApi'; + +export default function ProfileEditForm({ user }) { + const updateMutation = useUpdateProfileMutation(); + const isUpdating = updateMutation.isPending; + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: yupResolver(profileSchema), + }); + + // โหลดข้อมูลผู้ใช้เข้า Form เมื่อ Component โหลด + useEffect(() => { + if (user) { + reset({ + first_name: user.first_name || '', + last_name: user.last_name || '', + phone_number: user.phone_number || '', + email: user.email || '', + // ไม่โหลด username เพราะอาจมีปัญหาในการอัปเดต + }); + } + }, [user, reset]); + + const onSubmit = (data) => { + // Djoser /users/me/ Endpoint รองรับ PATCH เพื่อส่งเฉพาะ Field ที่เปลี่ยน + updateMutation.mutate(data); + }; + + return ( +
+ + + + + +

Username: {user.username} (ไม่สามารถแก้ไขได้)

+ + + + ); +} \ No newline at end of file diff --git a/web/src/config/sidebarRoutes.jsx b/web/src/config/sidebarRoutes.jsx index 5ed4e16..ccd0297 100644 --- a/web/src/config/sidebarRoutes.jsx +++ b/web/src/config/sidebarRoutes.jsx @@ -1,4 +1,4 @@ -import { FaTachometerAlt, FaCog, FaDatabase, FaHeartbeat, FaFlask } from 'react-icons/fa'; +import {FaTachometerAlt, FaCog, FaHeartbeat, FaFlask, FaUserCircle} from 'react-icons/fa'; const routes = [ { @@ -29,6 +29,14 @@ const routes = [ ], }, + // --- เพิ่มเมนูจัดการโปรไฟล์ --- + { + path: '/dashboard/profile', + icon: , + name: 'การจัดการโปรไฟล์', + requiredRole: ['viewer', 'admin', 'manager'], // ทุก Role ควรเข้าถึงได้ + }, + // ---------------------------------- // กลุ่ม: การดูแลระบบ // ---------------------------------- diff --git a/web/src/features/auth/authSlice.js b/web/src/features/auth/authSlice.js index b49d7a8..60625fd 100644 --- a/web/src/features/auth/authSlice.js +++ b/web/src/features/auth/authSlice.js @@ -67,11 +67,9 @@ const authSlice = createSlice({ // Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์) updateUser: (state, action) => { state.user = action.payload.user; - // คำนวณ Role ใหม่จากข้อมูลที่อัปเดต - const newRole = determineRole(action.payload.user); - state.role = newRole; + state.role = action.payload.role; // อัปเดต Role ด้วย (เผื่อมีการอัปเดตสิทธิ์) localStorage.setItem('user', JSON.stringify(action.payload.user)); - // ไม่ต้องยุ่งกับ token/role storage ในนี้ถ้ามี loginSuccess/logout จัดการแล้ว + localStorage.setItem('role', action.payload.role); } }, }); diff --git a/web/src/pages/Profile.jsx b/web/src/pages/Profile.jsx new file mode 100644 index 0000000..a57ba68 --- /dev/null +++ b/web/src/pages/Profile.jsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import TitleCard from '../components/TitleCard'; +import { useUserQuery } from '../services/authApi'; +import ProfileEditForm from '../components/Profile/ProfileEditForm'; +import PasswordChangeForm from '../components/Profile/PasswordChangeForm'; +import { FaUserCircle, FaLock } from 'react-icons/fa'; + +export default function Profile() { + const { data: user, isLoading, isError } = useUserQuery(); + const [activeTab, setActiveTab] = useState('profile'); + + if (isLoading) return

กำลังโหลดข้อมูลผู้ใช้...

; + if (isError) return

ไม่สามารถดึงข้อมูลโปรไฟล์ได้

; + + return ( + +
+ + +
+ + {activeTab === 'profile' && } + {activeTab === 'password' && } + +
+ ); +} \ No newline at end of file diff --git a/web/src/routes/pageRoutes.jsx b/web/src/routes/pageRoutes.jsx index f248ef0..06cb992 100644 --- a/web/src/routes/pageRoutes.jsx +++ b/web/src/routes/pageRoutes.jsx @@ -4,6 +4,7 @@ import ModelRegistry from '../pages/data/ModelRegistry'; import InferenceRun from '../pages/data/InferenceRun'; import SystemHealth from '../pages/system/Health'; import UserGuide from '../pages/system/UserGuide'; +import Profile from '../pages/Profile'; // Array ของเส้นทางย่อยภายใต้ /dashboard/ const pageRoutes = [ @@ -36,6 +37,11 @@ const pageRoutes = [ path: 'guide', element: , }, + // --- Profile Management --- + { + path: 'profile', + element: , + }, // Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ { path: '*', diff --git a/web/src/schemas/authSchema.js b/web/src/schemas/authSchema.js index cfa9d96..b0d669b 100644 --- a/web/src/schemas/authSchema.js +++ b/web/src/schemas/authSchema.js @@ -24,4 +24,19 @@ export const resetConfirmSchema = yup.object().shape({ re_new_password: yup.string() .oneOf([yup.ref('new_password'), null], 'รหัสผ่านไม่ตรงกัน') .required('กรุณายืนยันรหัสผ่านใหม่'), +}); + +export const profileSchema = yup.object().shape({ + first_name: yup.string().nullable(), + last_name: yup.string().nullable(), + phone_number: yup.string().nullable().matches(/^[0-9]*$/, 'เบอร์โทรศัพท์ต้องเป็นตัวเลขเท่านั้น'), + email: yup.string().email('รูปแบบอีเมลไม่ถูกต้อง').required('ต้องระบุอีเมล'), +}); + +export const passwordSchema = yup.object().shape({ + current_password: yup.string().required('กรุณากรอกรหัสผ่านปัจจุบัน'), + new_password: yup.string().required('กรุณากรอกรหัสผ่านใหม่').min(8, 'รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร'), + re_new_password: yup.string() + .oneOf([yup.ref('new_password'), null], 'รหัสผ่านใหม่ไม่ตรงกัน') + .required('กรุณายืนยันรหัสผ่านใหม่'), }); \ No newline at end of file diff --git a/web/src/services/authApi.js b/web/src/services/authApi.js index eae8985..57945de 100644 --- a/web/src/services/authApi.js +++ b/web/src/services/authApi.js @@ -1,9 +1,10 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useDispatch } from 'react-redux'; -import { loginSuccess } from '../features/auth/authSlice'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useDispatch, useSelector } from 'react-redux'; +import { loginSuccess, updateUser } from '../features/auth/authSlice'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; // ใช้ Axios ธรรมดาสำหรับการเรียกที่ยังไม่มี Token import { addToast } from '../features/toast/toastSlice'; +import axiosClient from "./axiosClient.js"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; @@ -141,7 +142,10 @@ export const useLoginMutation = () => { // 3. นำทางผู้ใช้ navigate('/dashboard', { replace: true }); + // Invalidating Query ที่เกี่ยวข้อง queryClient.invalidateQueries({ queryKey: ['userData'] }); + queryClient.invalidateQueries({ queryKey: ['userProfile'] }); + queryClient.invalidateQueries({ queryKey: ['modelList'] }); }, onError: (error) => { // error.message ถูกโยนมาจาก loginUser function @@ -280,4 +284,57 @@ export const refreshAccessToken = async () => { // คืนค่า Access Token ใหม่ return response.data.access; +}; + +// ---------------------------------------------------- +// Hook ดึงข้อมูลผู้ใช้ปัจจุบัน (GET /users/me/) +// ---------------------------------------------------- +export const useUserQuery = () => { + // ดึงสถานะปัจจุบันของ User จาก Redux Store + const userId = useSelector(state => state.auth.user?.id); + const isAuthenticated = useSelector(state => state.auth.isAuthenticated); + + return useQuery({ + // ทำให้ Query Key ขึ้นอยู่กับ User ID + queryKey: ['userProfile', userId], + + // ไม่รัน Query ถ้าผู้ใช้ไม่ได้ล็อกอิน + enabled: isAuthenticated && !!userId, + + queryFn: async () => { + const response = await axiosClient.get(`${API_BASE_URL}/api/v1/auth/users/me/`); + return response.data; + }, + staleTime: 60000, + }); +}; + +// ---------------------------------------------------- +// Hook อัปเดตข้อมูลผู้ใช้ (PATCH /users/me/) +// ---------------------------------------------------- +export const useUpdateProfileMutation = () => { + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + + return useMutation({ + mutationFn: async (profileData) => { + // Djoser Endpoint: PATCH /users/me/ + const response = await axiosClient.patch(`${API_BASE_URL}/api/v1/auth/users/me/`, profileData); + return response.data; + }, + onSuccess: (updatedUser) => { + dispatch(addToast({ message: 'แก้ไขข้อมูลส่วนตัวสำเร็จแล้ว!', type: 'success' })); + + // อัปเดต Redux Store และ Local Storage ทันที + const newRole = determineRole(updatedUser); + dispatch(updateUser({ user: updatedUser, role: newRole })); + + // Invalidate Query เพื่อดึงข้อมูล Profile ใหม่ + queryClient.invalidateQueries({ queryKey: ['userProfile'] }); + }, + onError: (error) => { + const msg = error.response?.data?.email || error.response?.data?.detail || 'การอัปเดตล้มเหลว'; + dispatch(addToast({ message: `อัปเดต Profile ล้มเหลว: ${msg}`, type: 'error' })); + }, + }); }; \ No newline at end of file