พัฒนา Frontend เพิ่มฟังก์ชัน แก้ไขข้อมูลส่วนตัว
This commit is contained in:
parent
98fcaeba3f
commit
4231d66789
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
78
web/src/components/Profile/PasswordChangeForm.jsx
Normal file
78
web/src/components/Profile/PasswordChangeForm.jsx
Normal file
@ -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 (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
|
||||
<InputText labelTitle="รหัสผ่านปัจจุบัน" type="password" {...register('current_password')} error={errors.current_password} />
|
||||
<InputText labelTitle="รหัสผ่านใหม่" type="password" {...register('new_password')} error={errors.new_password} />
|
||||
<InputText labelTitle="ยืนยันรหัสผ่านใหม่" type="password" {...register('re_new_password')} error={errors.re_new_password} />
|
||||
|
||||
{changeMutation.isError && (
|
||||
<ErrorText styleClass="mt-4">
|
||||
{errors.current_password?.message || changeMutation.error.message || 'เกิดข้อผิดพลาดในการเปลี่ยนรหัสผ่าน'}
|
||||
</ErrorText>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={"btn btn-warning mt-6" + (isChanging ? " loading" : "")}
|
||||
disabled={isChanging}
|
||||
>
|
||||
{isChanging ? 'กำลังดำเนินการ...' : 'เปลี่ยนรหัสผ่าน'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
52
web/src/components/Profile/ProfileEditForm.jsx
Normal file
52
web/src/components/Profile/ProfileEditForm.jsx
Normal file
@ -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 (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
|
||||
<InputText labelTitle="อีเมล (จำเป็น)" {...register('email')} error={errors.email} />
|
||||
<InputText labelTitle="ชื่อจริง" {...register('first_name')} error={errors.first_name} />
|
||||
<InputText labelTitle="นามสกุล" {...register('last_name')} error={errors.last_name} />
|
||||
<InputText labelTitle="เบอร์โทรศัพท์" {...register('phone_number')} error={errors.phone_number} />
|
||||
|
||||
<p className="text-sm text-warning mt-4">Username: {user.username} (ไม่สามารถแก้ไขได้)</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={"btn btn-primary mt-6" + (isUpdating ? " loading" : "")}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{isUpdating ? 'กำลังบันทึก...' : 'บันทึกการเปลี่ยนแปลง'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@ -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: <FaUserCircle className="w-5 h-5 flex-shrink-0" />,
|
||||
name: 'การจัดการโปรไฟล์',
|
||||
requiredRole: ['viewer', 'admin', 'manager'], // ทุก Role ควรเข้าถึงได้
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// กลุ่ม: การดูแลระบบ
|
||||
// ----------------------------------
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
37
web/src/pages/Profile.jsx
Normal file
37
web/src/pages/Profile.jsx
Normal file
@ -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 <p className="text-center p-8">กำลังโหลดข้อมูลผู้ใช้...</p>;
|
||||
if (isError) return <p className="text-error text-center p-8">ไม่สามารถดึงข้อมูลโปรไฟล์ได้</p>;
|
||||
|
||||
return (
|
||||
<TitleCard title="การจัดการโปรไฟล์" topMargin="mt-0">
|
||||
<div className="flex space-x-4 mb-6 border-b border-base-200">
|
||||
<button
|
||||
className={`btn btn-sm ${activeTab === 'profile' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setActiveTab('profile')}
|
||||
>
|
||||
<FaUserCircle className='w-4 h-4 mr-2'/> แก้ไขข้อมูลส่วนตัว
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${activeTab === 'password' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => setActiveTab('password')}
|
||||
>
|
||||
<FaLock className='w-4 h-4 mr-2'/> เปลี่ยนรหัสผ่าน
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile' && <ProfileEditForm user={user} />}
|
||||
{activeTab === 'password' && <PasswordChangeForm />}
|
||||
|
||||
</TitleCard>
|
||||
);
|
||||
}
|
||||
@ -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: <UserGuide />,
|
||||
},
|
||||
// --- Profile Management ---
|
||||
{
|
||||
path: 'profile',
|
||||
element: <Profile />,
|
||||
},
|
||||
// Fallback สำหรับเส้นทางที่ไม่ตรงกับเมนูใดๆ
|
||||
{
|
||||
path: '*',
|
||||
|
||||
@ -25,3 +25,18 @@ export const resetConfirmSchema = yup.object().shape({
|
||||
.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('กรุณายืนยันรหัสผ่านใหม่'),
|
||||
});
|
||||
@ -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
|
||||
@ -281,3 +285,56 @@ 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' }));
|
||||
},
|
||||
});
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user