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 (
+
+ );
+}
\ 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 (
+
+ );
+}
\ 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