Skip to content

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)

js
// 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

  1. RSA public key ดึงจาก backend — ถ้า backend ล่ม ผู้ใช้ login ไม่ได้
  2. เก็บ token ทั้งใน localStorage และ sessionStorage — ทำให้มี 2 แหล่งเก็บที่ต้อง sync
  3. มี index_bk.js ในโฟลเดอร์ — snapshot เก่า ห้ามใช้เป็นต้นแบบ
  4. ไม่มี rate limiting ฝั่ง frontend — ถ้าผู้ใช้กด login ผิดซ้ำ ไม่มีการ lock ชั่วคราว (ต้องพึ่ง backend)
  5. ไม่มี CAPTCHA — อาจถูก brute force (แต่ RSA + backend rate limit ช่วยได้บ้าง)

SSO path — /pathLogin/:token/:refreshToken

นอกจาก login ปกติ ยังมี PathLogin component ที่รับ token + refreshToken จาก URL — สำหรับ SSO จากระบบอื่น

js
// 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)

js
// 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

  1. formRef.current.resetFields() / setFieldsValue() — ใช้ ref ควบคุม form จากนอก form
  2. setTimeout(() => ..., 0) — รอให้ modal render ก่อนแล้วค่อย set field (workaround)
  3. fetchPolicy: "no-cache" — ดึงข้อมูลใหม่เสมอ
  4. fetchUsers() หลัง mutation — refresh table หลัง create/update/delete
  5. loading state สำหรับตาราง — ทำให้ UX ดีขึ้น

ข้อระวังเรื่อง ManageUser

  1. ไม่มี optimistic update — ผู้ใช้ต้องรอ refresh ก่อนเห็นผล
  2. fetchUsers() ซ้ำหลังทุก action — ถ้า table ใหญ่ จะช้า
  3. setTimeout workaround — บางครั้ง 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 — ย่อมาก)

js
// 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

  1. Tabs สำหรับแบ่งฟอร์มใหญ่ — ลดความซับซ้อนในแต่ละหน้าจอ
  2. getBase64() สำหรับ upload — แปลงไฟล์เป็น base64 แล้วเก็บใน state — ส่งไป server ตอน submit (ไม่ได้ใช้ multipart)
  3. validateFields() ที่ครอบทุก tab — validation ทำครั้งเดียว แม้ข้อมูลกระจายหลาย tab
  4. findTabOfField() — helper ที่หาว่าฟิลด์ที่ error อยู่ tab ไหน เพื่อสลับไปให้ผู้ใช้เห็น
  5. editId จาก URL — ถ้ามี id ใน params → mode แก้ไข, ถ้าไม่มี → mode สร้าง

ข้อระวังเรื่องฟอร์มใหญ่

  1. base64 ทำให้ payload ใหญ่ — ไฟล์ 1 MB จะกลายเป็น ~1.3 MB ใน JSON
  2. state.attachments โตเร็ว — ถ้าผู้ใช้ upload หลายไฟล์ browser อาจหน่วย
  3. ไม่มี autosave — ถ้าผู้ใช้ปิด browser กลางคัน ข้อมูลหาย
  4. 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:

js
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

js
import { message } from "antd";

message.success("สำเร็จ");
message.error("ล้มเหลว");
alert("ข้อความ"); // ใช้บ้าง

5. this.props.history.push() สำหรับ redirect

js
this.props.history.push("/home");

เปรียบเทียบ 3 หน้า

ด้านLoginManageUserLeaveStudiesPayments
ขนาด418 บรรทัด762 บรรทัด1,824 บรรทัด
Complexityต่ำปานกลางสูง
Stateน้อย (2-3 ฟิลด์)ปานกลาง (table + pagination + modal)เยอะ (10 tab + attachments)
Mutation1 (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 ย่อย

ขั้นตอนถัดไป

ไป บท 7.8 State, styling, locale