8.7 · Page deep dive — Login, ManageUser, LeaveStudiesPayments
บทนี้เจาะลึก 3 หน้าสำคัญ เพื่อให้เห็น pattern จริงของ vtrc-rc-backoffice — Login (auth), ManageUser (CRUD + role), LeaveStudiesPayments (form ใหญ่ + multi-tab + upload)
1. Login — src/pages/Login/index.js (418 บรรทัด)
หน้า login ของ backoffice ต่างจาก vtrc-web ตรงมี RSA encrypt และใช้ loginBackoffice mutation (ไม่ใช่ login)
ลำดับการทำงาน
ผู้ใช้กรอก username + password
↓
[1] ดึง RSA public key จาก backend
↓
[2] encrypt password ด้วย JSEncrypt
↓
[3] ส่ง loginBackoffice(username, encryptedPassword)
↓
[4] backend ตรวจ → ส่ง token + refreshToken
↓
[5] เก็บ token ใน localStorage + sessionStorage
↓
[6] redirect ไป /homeโค้ดตัวอย่าง (concept)
// src/pages/Login/index.js (concept)
import JSEncrypt from "jsencrypt";
import { useMutation } from "@apollo/react-hooks";
import Mutations from "../../graphql/mutations";
class Login extends React.Component {
state = {
username: "",
password: "",
loading: false,
publicKey: null,
};
async componentDidMount() {
// [1] ดึง RSA public key
const { data } = await axios.get("/api/rsa-key");
this.setState({ publicKey: data.publicKey });
}
handleSubmit = async () => {
this.setState({ loading: true });
// [2] encrypt password
const encrypt = new JSEncrypt();
encrypt.setPublicKey(this.state.publicKey);
const encryptedPassword = encrypt.encrypt(this.state.password);
// [3] login
const { data } = await this.props.client.mutate({
mutation: Mutations.LOGIN_BACKOFFICE,
variables: {
username: this.state.username,
password: encryptedPassword,
},
});
// [4] ตรวจผล
if (data.loginBackoffice.success) {
// [5] เก็บ token
localStorage.setItem("token", data.loginBackoffice.token);
localStorage.setItem("refreshToken", data.loginBackoffice.refreshToken);
sessionStorage.setItem("token", data.loginBackoffice.token);
// [6] redirect
this.props.history.push("/home");
} else {
alert(data.loginBackoffice.message);
}
this.setState({ loading: false });
};
render() {
return (
<Form>
<Form.Item label="Username">
<Input
value={this.state.username}
onChange={(e) => this.setState({ username: e.target.value })}
/>
</Form.Item>
<Form.Item label="Password">
<Input.Password
value={this.state.password}
onChange={(e) => this.setState({ password: e.target.value })}
/>
</Form.Item>
<Button loading={this.state.loading} onClick={this.handleSubmit}>
Login
</Button>
</Form>
);
}
}
export default withApollo(Login);ข้อระวังเรื่อง Login
- RSA public key ดึงจาก backend — ถ้า backend ล่ม ผู้ใช้ login ไม่ได้
- เก็บ token ทั้งใน localStorage และ sessionStorage — ทำให้มี 2 แหล่งเก็บที่ต้อง sync
- มี
index_bk.jsในโฟลเดอร์ — snapshot เก่า ห้ามใช้เป็นต้นแบบ - ไม่มี rate limiting ฝั่ง frontend — ถ้าผู้ใช้กด login ผิดซ้ำ ไม่มีการ lock ชั่วคราว (ต้องพึ่ง backend)
- ไม่มี CAPTCHA — อาจถูก brute force (แต่ RSA + backend rate limit ช่วยได้บ้าง)
SSO path — /pathLogin/:token/:refreshToken
นอกจาก login ปกติ ยังมี PathLogin component ที่รับ token + refreshToken จาก URL — สำหรับ SSO จากระบบอื่น
// src/pages/PathLogin/index.js (concept)
class PathLogin extends React.Component {
componentDidMount() {
const { token, refreshToken } = this.props.match.params;
// เก็บ token จาก URL
localStorage.setItem("token", token);
localStorage.setItem("refreshToken", refreshToken);
// redirect ไปหน้าแรก
this.props.history.push("/home");
}
render() {
return <LoadingPage />;
}
}ปัญหา: token อยู่ใน URL — อาจรั่วผ่าน browser history, referer header, server log (ดู บท 7.9)
2. ManageUser — src/pages/ManageUser/index.js (762 บรรทัด)
หน้าจัดการผู้ใช้ + role + permission — เป็นตัวอย่างของ CRUD + table + modal
ลำดับการทำงาน
[1] mount → fetch users + roles
↓
[2] render table พร้อม search + pagination
↓
[3] ผู้ใช้กด "Add" → modal ฟอร์ม
↓
[4] submit → createUser mutation → refresh table
↓
[5] ผู้ใช้กด "Edit" → modal ฟอร์ม (pre-fill)
↓
[6] submit → updateUser mutation → refresh table
↓
[7] ผู้ใช้กด "Delete" → confirm → deleteUser mutationโค้ดตัวอย่าง (concept)
// src/pages/ManageUser/index.js (concept)
class ManageUser extends React.Component {
formRef = React.createRef();
state = {
users: [],
roles: [],
loading: false,
modalVisible: false,
editingUser: null,
pagination: { page: 1, limit: 20, total: 0 },
search: "",
};
async componentDidMount() {
await Promise.all([
this.fetchUsers(),
this.fetchRoles(),
]);
}
fetchUsers = async () => {
this.setState({ loading: true });
const { data } = await this.props.client.query({
query: Queries.FETCH_USERS,
variables: {
page: this.state.pagination.page,
limit: this.state.pagination.limit,
search: this.state.search,
},
fetchPolicy: "no-cache",
});
this.setState({
users: data.fetchUsers.items,
pagination: { ...this.state.pagination, total: data.fetchUsers.total },
loading: false,
});
};
fetchRoles = async () => {
const { data } = await this.props.client.query({
query: Queries.FETCH_ROLES,
});
this.setState({ roles: data.fetchRoles });
};
handleAdd = () => {
this.setState({ editingUser: null, modalVisible: true });
setTimeout(() => this.formRef.current.resetFields(), 0);
};
handleEdit = (user) => {
this.setState({ editingUser: user, modalVisible: true });
setTimeout(() => this.formRef.current.setFieldsValue(user), 0);
};
handleSubmit = async (values) => {
if (this.state.editingUser) {
await this.props.client.mutate({
mutation: Mutations.UPDATE_USER,
variables: { id: this.state.editingUser.id, input: values },
});
} else {
await this.props.client.mutate({
mutation: Mutations.CREATE_USER,
variables: { input: values },
});
}
this.setState({ modalVisible: false });
this.fetchUsers();
};
handleDelete = async (userId) => {
await this.props.client.mutate({
mutation: Mutations.DELETE_USER,
variables: { id: userId },
});
this.fetchUsers();
};
render() {
const columns = [
{ title: "ชื่อ", dataIndex: "firstName" },
{ title: "นามสกุล", dataIndex: "lastName" },
{ title: "Username", dataIndex: "username" },
{ title: "Role", dataIndex: ["role", "name"] },
{
title: "Action",
render: (_, user) => (
<>
<Button onClick={() => this.handleEdit(user)}>Edit</Button>
<Popconfirm onConfirm={() => this.handleDelete(user.id)}>
<Button danger>Delete</Button>
</Popconfirm>
</>
),
},
];
return (
<>
<Button onClick={this.handleAdd}>+ Add</Button>
<Input.Search
placeholder="ค้นหา"
onSearch={(v) => {
this.setState({ search: v, pagination: { ...this.state.pagination, page: 1 } }, this.fetchUsers);
}}
/>
<Table
columns={columns}
dataSource={this.state.users}
loading={this.state.loading}
pagination={{
...this.state.pagination,
onChange: (page) => {
this.setState({ pagination: { ...this.state.pagination, page } }, this.fetchUsers);
},
}}
/>
<Modal visible={this.state.modalVisible} footer={null}>
<Form ref={this.formRef} onFinish={this.handleSubmit}>
<Form.Item name="firstName" label="ชื่อ" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="lastName" label="นามสกุล" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="username" label="Username" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="roleId" label="Role" rules={[{ required: true }]}>
<Select>
{this.state.roles.map((r) => (
<Select.Option key={r.id} value={r.id}>{r.name}</Select.Option>
))}
</Select>
</Form.Item>
<Button htmlType="submit">Save</Button>
</Form>
</Modal>
</>
);
}
}
export default withApollo(ManageUser);Pattern สำคัญใน ManageUser
formRef.current.resetFields()/setFieldsValue()— ใช้ ref ควบคุม form จากนอก formsetTimeout(() => ..., 0)— รอให้ modal render ก่อนแล้วค่อย set field (workaround)fetchPolicy: "no-cache"— ดึงข้อมูลใหม่เสมอfetchUsers()หลัง mutation — refresh table หลัง create/update/deleteloadingstate สำหรับตาราง — ทำให้ UX ดีขึ้น
ข้อระวังเรื่อง ManageUser
- ไม่มี optimistic update — ผู้ใช้ต้องรอ refresh ก่อนเห็นผล
fetchUsers()ซ้ำหลังทุก action — ถ้า table ใหญ่ จะช้าsetTimeoutworkaround — บางครั้ง modal ยังไม่ render ทำให้setFieldsValueไม่ทำงาน
3. LeaveStudiesPayments — src/pages/LeaveStudiesPayments/form/index.js (1,824 บรรทัด)
หน้าฟอร์มเบิกค่าเล่าเรียน — เป็นตัวอย่างของฟอร์มใหญ่ multi-tab + upload + approval flow
โครงสร้างฟอร์ม
Form (parent)
├── Tab1: ข้อมูลผู้ขอ (ผู้ใช้, ตำแหน่ง, หน่วยงาน)
├── Tab2: รายละเอียดหลักสูตร (ชื่อ, สถาบัน, วันที่, ค่าใช้จ่าย)
├── Tab3: เอกสารแนบ (transcript, ใบเสร็จ)
├── Tab4: ประวัติการศึกษา
├── Tab5: ผู้อนุมัติ (หัวหน้า, HR)
├── Tab6: สถานะการอนุมัติ
├── Tab7: การชำระเงิน (ประวัติ)
├── Tab8: หมายเหตุ / comment
├── Tab9: เอกสารเพิ่มเติม
└── Tab10: สรุปลำดับการทำงาน
[1] mount → ถ้ามี id ให้ fetch ข้อมูลมา pre-fill
↓
[2] ผู้ใช้กรอกข้อมูลในแต่ละ tab
↓
[3] upload ไฟล์ → getBase64 → เก็บใน state (ส่งไป server ตอน submit)
↓
[4] submit → ตรวจทุก tab → ส่ง mutation
↓
[5] redirect ไปหน้า listโค้ดตัวอย่าง (concept — ย่อมาก)
// src/pages/LeaveStudiesPayments/form/index.js (concept)
class LeaveStudiesPaymentsForm extends React.Component {
formRef = React.createRef();
state = {
activeTab: "tab1",
attachments: [],
loading: false,
editId: null,
};
async componentDidMount() {
const editId = this.props.match.params.id;
if (editId) {
this.setState({ editId });
const { data } = await this.props.client.query({
query: Queries.FETCH_LEAVE_STUDIES_PAYMENTS,
variables: { id: editId },
});
this.formRef.current.setFieldsValue(data.fetchLeaveStudiesPayments);
this.setState({ attachments: data.fetchLeaveStudiesPayments.attachments });
}
}
handleUpload = async (file) => {
const base64 = await getBase64(file);
this.setState({
attachments: [
...this.state.attachments,
{ filename: file.name, base64, type: file.type },
],
});
return false; // ป้องกัน AntD upload auto
};
handleSubmit = async () => {
try {
const values = await this.formRef.current.validateFields();
this.setState({ loading: true });
const mutation = this.state.editId
? Mutations.UPDATE_LEAVE_STUDIES_PAYMENT
: Mutations.CREATE_LEAVE_STUDIES_PAYMENT;
await this.props.client.mutate({
mutation,
variables: {
id: this.state.editId,
input: {
...values,
attachments: this.state.attachments,
},
},
});
message.success("บันทึกสำเร็จ");
this.props.history.push("/leaveStudiesPayments");
} catch (err) {
if (err.errorFields) {
// validation error
const firstErrorTab = this.findTabOfField(err.errorFields[0].name[0]);
this.setState({ activeTab: firstErrorTab });
} else {
message.error("เกิดข้อผิดพลาด");
}
} finally {
this.setState({ loading: false });
}
};
render() {
return (
<Form ref={this.formRef} layout="vertical">
<Tabs activeKey={this.state.activeTab} onChange={(k) => this.setState({ activeTab: k })}>
<Tabs.TabPane tab="ข้อมูลผู้ขอ" key="tab1">
<Form.Item name="userId" label="ผู้ขอ" rules={[{ required: true }]}>
<Select>{/* ... */}</Select>
</Form.Item>
{/* ... 20+ fields */}
</Tabs.TabPane>
<Tabs.TabPane tab="รายละเอียดหลักสูตร" key="tab2">
{/* ... */}
</Tabs.TabPane>
{/* ... 8 more tabs */}
<Tabs.TabPane tab="เอกสารแนบ" key="tab3">
<Upload beforeUpload={this.handleUpload}>
<Button>เพิ่มไฟล์</Button>
</Upload>
{this.state.attachments.map((att, i) => (
<div key={i}>{att.filename}</div>
))}
</Tabs.TabPane>
</Tabs>
<Button loading={this.state.loading} onClick={this.handleSubmit}>
บันทึก
</Button>
</Form>
);
}
}Pattern สำคัญใน LeaveStudiesPayments
Tabsสำหรับแบ่งฟอร์มใหญ่ — ลดความซับซ้อนในแต่ละหน้าจอgetBase64()สำหรับ upload — แปลงไฟล์เป็น base64 แล้วเก็บใน state — ส่งไป server ตอน submit (ไม่ได้ใช้ multipart)validateFields()ที่ครอบทุก tab — validation ทำครั้งเดียว แม้ข้อมูลกระจายหลาย tabfindTabOfField()— helper ที่หาว่าฟิลด์ที่ error อยู่ tab ไหน เพื่อสลับไปให้ผู้ใช้เห็นeditIdจาก URL — ถ้ามีidใน params → mode แก้ไข, ถ้าไม่มี → mode สร้าง
ข้อระวังเรื่องฟอร์มใหญ่
- base64 ทำให้ payload ใหญ่ — ไฟล์ 1 MB จะกลายเป็น ~1.3 MB ใน JSON
- state.attachments โตเร็ว — ถ้าผู้ใช้ upload หลายไฟล์ browser อาจหน่วย
- ไม่มี autosave — ถ้าผู้ใช้ปิด browser กลางคัน ข้อมูลหาย
validateFields()validate ทุกฟิลด์พร้อมกัน — ถ้าฟิลด์เยอะ อาจใช้เวลานาน
รูปแบบไฟล์
src/pages/LeaveStudiesPayments/
├── index.js ← list view
├── form/
│ ├── index.js ← form (1,824 บรรทัด)
│ ├── index-axios.js ← ทดลอง impl ด้วย axios (legacy — ห้ามใช้)
│ └── components/ ← sub-component สำหรับ tab
└── approval/ ← หน้าอนุมัติ (HR)Pattern ที่พบในทั้ง 3 หน้า
1. withApollo + this.props.client
ทั้ง 3 หน้าเป็น class component ที่ใช้ withApollo HOC เพื่อเข้าถึง Apollo Client:
export default withApollo(MyPage);2. fetchPolicy: "no-cache" เสมอ
ทุก query ใส่ fetchPolicy: "no-cache" ทำให้ข้อมูลใหม่เสมอ
3. loading state สำหรับ UX
ทุกหน้ามี state.loading ที่ set ระหว่างรอ API เพื่อแสดง spinner
4. message และ alert สำหรับ feedback
import { message } from "antd";
message.success("สำเร็จ");
message.error("ล้มเหลว");
alert("ข้อความ"); // ใช้บ้าง5. this.props.history.push() สำหรับ redirect
this.props.history.push("/home");เปรียบเทียบ 3 หน้า
| ด้าน | Login | ManageUser | LeaveStudiesPayments |
|---|---|---|---|
| ขนาด | 418 บรรทัด | 762 บรรทัด | 1,824 บรรทัด |
| Complexity | ต่ำ | ปานกลาง | สูง |
| State | น้อย (2-3 ฟิลด์) | ปานกลาง (table + pagination + modal) | เยอะ (10 tab + attachments) |
| Mutation | 1 (LOGIN_BACKOFFICE) | 3 (CREATE/UPDATE/DELETE) | 1-2 (CREATE/UPDATE) |
| File upload | ไม่มี | ไม่มี | มี (base64) |
| Multi-tab | ไม่มี | ไม่มี | มี (10 tab) |
บทเรียนที่ได้
สำหรับหน้าใหม่ที่จะสร้าง
- ถ้าเป็นฟอร์มเล็ก — ทำตาม pattern ของ Login (state น้อย, ไม่มี tab)
- ถ้าเป็น CRUD ตาราง — ทำตาม ManageUser (table + modal + pagination)
- ถ้าเป็นฟอร์มใหญ่ — ทำตาม LeaveStudiesPayments (Tabs + sub-component + upload)
สิ่งที่ไม่ควรทำซ้ำ
- base64 upload — ควรใช้ multipart/form-data แทน (ลด payload)
- state.attachments โต — ควร upload ทันที แล้วเก็บ URL
- ไฟล์ 1,800+ บรรทัด — ควรแยกเป็น component ย่อย