พัฒนา Frontend เพิ่มฟังก์ชัน แก้ไขข้อมูลส่วนตัว

This commit is contained in:
Flook 2025-11-16 14:23:43 +07:00
parent 98fcaeba3f
commit 4231d66789
9 changed files with 266 additions and 15 deletions

View File

@ -8,13 +8,13 @@ Frontend Console สำหรับการจัดการ **AI Model Regist
## 💡 สถาปัตยกรรมและเทคโนโลยีหลัก ## 💡 สถาปัตยกรรมและเทคโนโลยีหลัก
| องค์ประกอบ | เทคโนโลยี | บทบาท / เหตุผล | | องค์ประกอบ | เทคโนโลยี | บทบาท / เหตุผล |
| :--- | :--- | :--- | | :--- |:-----------------------------------------------| :--- |
| **Framework** | 🧩 **React (Vite)** | ความเร็วสูง, Hot Reload, โครงสร้าง Component-Based | | **Framework** | 🧩 **React (Vite)** | ความเร็วสูง, Hot Reload, โครงสร้าง Component-Based |
| **Styling** | 🎨 **Tailwind CSS + DaisyUI** | Utility-first CSS พร้อมชุด Component ที่สวยงาม | | **Styling** | 🎨 **Tailwind CSS + DaisyUI** | Utility-first CSS พร้อมชุด Component ที่สวยงาม |
| **Server State / Data Fetching** | ⚙️ **TanStack Query (React Query)** | จัดการ Cache, Loading, Error, และ Data Synchronization | | **Server State / Data Fetching** | ⚙️ **TanStack Query (React Query)** | จัดการ Cache, Loading, Error, และ Data Synchronization |
| **Form Handling** | 🧠 **React Hook Form + Yup** | Validation ที่มีประสิทธิภาพ, ลดการ re-render | | **Form Handling** | 🧠 **React Hook Form + Yup** | Validation ที่มีประสิทธิภาพ, ลดการ re-render |
| **Security Flow** | 🔐 **JWT (Bearer/Interceptor)** | ใช้ `axiosClient` จัดการ Token, Refresh Token, และ Force Logout | | **Security Flow** | 🔐 **JWT + Axios Interceptor + Refresh Token** | ใช้ `axiosClient` จัดการ Token, Refresh Token, และ Force Logout |
--- ---

View 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>
);
}

View 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>
);
}

View File

@ -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 = [ 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
},
// ---------------------------------- // ----------------------------------
// : // :
// ---------------------------------- // ----------------------------------

View File

@ -67,11 +67,9 @@ const authSlice = createSlice({
// Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์) // Reducer สำหรับอัปเดตข้อมูลผู้ใช้ (เช่น หลังอัปเดตโปรไฟล์)
updateUser: (state, action) => { updateUser: (state, action) => {
state.user = action.payload.user; state.user = action.payload.user;
// คำนวณ Role ใหม่จากข้อมูลที่อัปเดต state.role = action.payload.role; // อัปเดต Role ด้วย (เผื่อมีการอัปเดตสิทธิ์)
const newRole = determineRole(action.payload.user);
state.role = newRole;
localStorage.setItem('user', JSON.stringify(action.payload.user)); 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
View 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>
);
}

View File

@ -4,6 +4,7 @@ import ModelRegistry from '../pages/data/ModelRegistry';
import InferenceRun from '../pages/data/InferenceRun'; import InferenceRun from '../pages/data/InferenceRun';
import SystemHealth from '../pages/system/Health'; import SystemHealth from '../pages/system/Health';
import UserGuide from '../pages/system/UserGuide'; import UserGuide from '../pages/system/UserGuide';
import Profile from '../pages/Profile';
// Array /dashboard/ // Array /dashboard/
const pageRoutes = [ const pageRoutes = [
@ -36,6 +37,11 @@ const pageRoutes = [
path: 'guide', path: 'guide',
element: <UserGuide />, element: <UserGuide />,
}, },
// --- Profile Management ---
{
path: 'profile',
element: <Profile />,
},
// Fallback // Fallback
{ {
path: '*', path: '*',

View File

@ -25,3 +25,18 @@ export const resetConfirmSchema = yup.object().shape({
.oneOf([yup.ref('new_password'), null], 'รหัสผ่านไม่ตรงกัน') .oneOf([yup.ref('new_password'), null], 'รหัสผ่านไม่ตรงกัน')
.required('กรุณายืนยันรหัสผ่านใหม่'), .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('กรุณายืนยันรหัสผ่านใหม่'),
});

View File

@ -1,9 +1,10 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { loginSuccess } from '../features/auth/authSlice'; import { loginSuccess, updateUser } from '../features/auth/authSlice';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import axios from 'axios'; // ใช้ Axios ธรรมดาสำหรับการเรียกที่ยังไม่มี Token import axios from 'axios'; // ใช้ Axios ธรรมดาสำหรับการเรียกที่ยังไม่มี Token
import { addToast } from '../features/toast/toastSlice'; import { addToast } from '../features/toast/toastSlice';
import axiosClient from "./axiosClient.js";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
@ -141,7 +142,10 @@ export const useLoginMutation = () => {
// 3. นำทางผู้ใช้ // 3. นำทางผู้ใช้
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
// Invalidating Query ที่เกี่ยวข้อง
queryClient.invalidateQueries({ queryKey: ['userData'] }); queryClient.invalidateQueries({ queryKey: ['userData'] });
queryClient.invalidateQueries({ queryKey: ['userProfile'] });
queryClient.invalidateQueries({ queryKey: ['modelList'] });
}, },
onError: (error) => { onError: (error) => {
// error.message ถูกโยนมาจาก loginUser function // error.message ถูกโยนมาจาก loginUser function
@ -281,3 +285,56 @@ export const refreshAccessToken = async () => {
// คืนค่า Access Token ใหม่ // คืนค่า Access Token ใหม่
return response.data.access; 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' }));
},
});
};