Skip to content

7.4 · Routing + auth gate + menu visibility

บทนี้เล่าว่า route ทำงานอย่างไร auth gate อยู่ตรงไหน และทำไมการซ่อนเมนูใน UI จึงไม่ใช่การคุมสิทธิ์จริง

ถ้ามีผู้ใช้รายงานว่า "เห็นเมนูที่ไม่ควรเห็น" หรือ "พิมพ์ URL แล้วเข้าได้ทั้งที่ไม่มีเมนู" — บทนี้คือคำอธิบาย


Route ทำงาน 2 ชั้น

ชั้นแรกคือ App/index.js ที่ตัดสินใจว่าผู้ใช้ login หรือไม่ ชั้นที่สองคือ routes.js + SiderLayout ที่วาดเมนูและเรนเดอร์ route ภายใน shell

ชั้นที่ 1 — App/index.js

17:33:vtrc-web/src/containers/App/index.js
	return (
		<BrowserRouter>
			<Switch>
				<Route path="/login" exact component={Login} />
				<Route path="/verify/user/A1" exact component={ConfirmUserA1} />
				<Route path="/verify/user/A2" exact component={ConfirmUserA2} />
				<Route path="/forgetpassword" exact component={ForgetPassword} />
				<Route path="/passwords/first/time" exact component={SetPasswordsFirstTime} />
				{isLoggedIn() && <Route path="/" component={SiderLayout} />}
				{!isLoggedIn() && <Redirect to="/login" />}
				<Route component={NotFound} />
			</Switch>
		</BrowserRouter>
	)
Pathเงื่อนไขComponent
/loginpublicLogin
/verify/user/A1, /verify/user/A2publicConfirmUserA1, ConfirmUserA2
/forgetpasswordpublicForgetPassword
/passwords/first/timepublicSetPasswordsFirstTime
/ (ทุก path)isLoggedIn() === trueSiderLayout (shell)
(default)isLoggedIn() === falseredirect /login

isLoggedIn() — auth gate ตัวเดียว

91:97:vtrc-web/src/utils/auth.js
export const isLoggedIn = () => {
	if (localStorage.getItem('isRemember')) {
		return !!localStorage.getItem('token') && !!localStorage.getItem('refreshToken')
	} else {
		return !!sessionStorage.getItem('token') && !!sessionStorage.getItem('refreshToken')
	}
}

เช็คเพียงว่ามี token + refreshToken ครบทั้งคู่ใน storage — ไม่ได้ตรวจ token validity

ผลที่ตามมา:

  • ถ้า token หมดอายุที่ server ผู้ใช้ยังผ่าน auth gate ได้ (เพราะ localStorage ยังมี token)
  • หน้าจะ render เข้าไปได้ แต่ทุก query จะ fail ด้วย TOKEN_EXPIRED แล้ว refresh token ใน errorLink
  • ถ้า refresh ไม่สำเร็จ errorLink จะ redirect ไป /login

ชั้นที่ 2 — SiderLayout + routes.js

SiderLayout เป็น shell ที่วาดเมนูซ้าย + header + content area และใช้ renderAllRoutes() จาก routes.js เพื่อ render <Switch> ภายใน

1405:1410:vtrc-web/src/config/routes.js
export const renderAllRoutes = () => (
  <Switch>
    {mapRoutes(routes)}
    {renderDefaultRoute()}
  </Switch>
);

mapRoutes วน routes ทั้งหมดแบบ recursive แล้ว flatten ให้เป็น <Route> ต่าง ๆ:

1387:1401:vtrc-web/src/config/routes.js
const mapRoutes = (routes, parentPath = "") => {
  let routeComponents = [];
  for (let route of routes) {
    if (route.children) {
      routeComponents = [
        ...routeComponents,
        ...mapRoutes(route.children, parentPath + route.path),
      ];
    }
    if (route.component) {
      routeComponents.push(renderRoute(route, parentPath));
    }
  }
  return routeComponents;
};

renderRoute สร้าง <Route> โดยใช้ render prop เพื่อส่ง props เพิ่มเติม:

1378:1385:vtrc-web/src/config/routes.js
  <Route
    exact
    key={basePath + route.path}
    path={basePath + route.path}
    render={(props) => <route.component {...props} {...route.props} />}
  />
);

โครงสร้าง route entry

จาก comment ใน routes.js:145-153:

145:153:vtrc-web/src/config/routes.js
/* Struct {
	key: String, 'unique key',
	label!: String 'display name on menu',
	icon!: String 'AntD icon',
	path!: String 'path for routing',
	isShow!: Boolean 'if it is true display on menu',
	component: React Node 'component to render',
	children?: Array 'sub route for routing'
} */

ตัวอย่าง entry จริง:

227:283:vtrc-web/src/config/routes.js
  {
    key: "hospital",
    label: "การเบิกค่ารักษาพยาบาล",
    icon: "appstore",
    path: "/hospital",
    isShow: true,
    children: [
      {
        label: "รายการเบิกทั้งหมด",
        path: "/search",
        component: HospitalSearchForm,
        isShow: true,
      },
      {
        label: "ผู้มีสิทธิเบิกค่ารักษาพยาบาล",
        path: "/create",
        component: HospitalForm,
        isShow: true,
      },
      {
        label: "เพิ่มขอเบิก",
        path: "/Withdraw",
        component: HospitalWithdraw,
        isShow: false,
        children: [
          {
            path: "/:postId",
            component: HospitalWithdrawDetail,
          },
        ],
      },
      ...
    ],
  },

ฟิลด์ที่ใช้บ่อย

ฟิลด์หน้าที่
keyid ที่ใช้เช็ค permission (เช่น isPmsEvaluate, isPmsIndicators)
labelข้อความบนเมนู
iconAntD icon name
pathURL path
isShowถ้า true แสดงบนเมนู ถ้า false ไม่แสดง แต่ route ยังเข้าได้
isShowSubสำหรับ submenu (PMS)
isShowSubmenuDynamicdynamic show/hide ตาม role flag
componentReact component ที่จะ render
childrennested route (recursive)

ข้อผิดพลาดที่พบบ่อยที่สุดในระบบคือ เข้าใจผิดว่า isShow: false หรือ key: "isPms..." เป็นการห้ามผู้ใช้เข้าถึง route

isShow เป็นเพียง flag ฝั่ง UI

isShow ใช้คุมว่าจะแสดงเมนูหรือไม่ — แต่ route ยังถูกสร้างโดย mapRoutes() ทั้งหมด ผู้ใช้ที่พิมพ์ URL ตรง ๆ จะเข้าได้เสมอ

ตัวอย่าง:

js
{
  label: "เพิ่มขอเบิก",
  path: "/Withdraw",
  component: HospitalWithdraw,
  isShow: false,         // ซ่อนเมนู แต่พิมพ์ /hospital/Withdraw แล้วเข้าได้
}

key: "isPms..." ก็เป็นเพียง flag ฝั่ง UI

PMS cluster มี key เช่น isPmsEvaluate, isPmsIndicators, isPmsEvaluation — เป็น id ที่ SiderLayout ใช้ตัดสินใจว่าจะแสดงเมนูนั้นหรือไม่ โดยอิงจากข้อมูลที่ได้จาก query getProfileByEmpID (ดู บท 6.6)

แต่ route ทั้งหมดยังถูกสร้างเสมอ ผู้ใช้ที่พิมพ์ /pms/indicators/list โดยตรงจะเข้าได้

การคุมสิทธิ์จริงอยู่ที่ฝั่ง server

ทุก query/mutation ฝั่ง vtrc-api มี @auth(accessRole: [...], accessMenu: [...]) directive (ดู Volume 3 บท 3.2) ที่ตรวจ JWT และ role จริง

ผู้ใช้ที่เจาะเข้า route /pms/indicators/list โดยไม่มีสิทธิ์จะเห็นหน้าว่าง เพราะ query getIndicatorsList จะตอบ FORBIDDEN

กฎเหล็ก

สิ่งที่ทำได้สิ่งที่ทำไม่ได้
ใช้ isShow: false เพื่อซ่อนเมนูที่ไม่ต้องการให้ผู้ใช้เห็นคิดว่า isShow: false ห้ามผู้ใช้เข้า route นั้น
ใช้ key: "isPms..." เพื่อ toggle เมนูตาม roleคิดว่า key เป็น gate ป้องกันการเข้าถึง
พิมพ์ URL ตรง ๆ เพื่อทดสอบ routeตั้งสมมติฐานว่าผู้ใช้จะคลิกเมนูเท่านั้น

ถ้าต้องการป้องกันการเข้าถึงจริงที่ฝั่ง frontend ต้องเขียน <PrivateRoute> ที่เช็ค role ก่อน render — แต่ตอนนี้ไม่มีอยู่ในระบบ


SiderLayout — shell ของทุกหน้า

src/components/user/SiderLayout/index.js:1-1226 — เป็น shell ขนาดใหญ่ที่:

  1. ดึง profile + role ของผู้ใช้ผ่าน this.props.client.query({ query: Queries.getProfileByEmpID })
  2. วาดเมนูซ้าย + header + content area
  3. เรียก renderAllRoutes() เพื่อใส่ <Switch> ใน content area
  4. คำนวณ isShow แต่ละเมนูตาม key + role flag ที่ได้จาก profile

ข้อควรระวังเกี่ยวกับ SiderLayout

  • ขนาด 1,227 บรรทัดทำให้แก้ไขยาก
  • logic เลือกเมนูกระจายอยู่หลายจุด — ถ้าจะเพิ่มเมนูต้องแก้ใน 2-3 ที่ (route entry + permission map + UI render)
  • มีการ query profile ทุกครั้งที่ reload ทำให้หน้าแรกช้ากว่าที่ควร

route clusters — จากบท 6.1

เพื่อให้ค้นหาง่าย ตารางนี้แมป cluster → path prefix ตามที่อยู่ใน routes.js:

ClusterPath prefixจำนวน route (โดยประมาณ)
Auth / onboarding/login, /verify/user/*, /forgetpassword, /passwords/*5 (public)
News/news, /notification~5
Hospital/hospital/*~12
PMS/pms/*, /pmsAssessment/*~25 (ใหญ่ที่สุด)
Leave/leave/*, /abstaintime/*~15
Welfare / Fund/welfare/*, /fund/*, /childEducationFee/*~10
Meeting/meetingManagement/*~15
Profile/profile/*, /empRegistration/*, /managementHR/*~10
Recruit/recruit/*~5
Course / Training/course/*, /orientation/*~5
Contact / About/contact/*, /abouts/*~10

เคล็ดลับตอนทำงานจริง

ตอนเพิ่ม route ใหม่

  1. import component ที่จะใช้ใน routes.js
  2. เพิ่ม entry ใน array routes (เลือก cluster ที่เหมาะสม)
  3. ตั้ง isShow: true ถ้าต้องการให้แสดงบนเมนู
  4. ถ้าเป็นหน้าที่ต้องคุมตาม role ให้ตั้ง key: "isXXX" แล้วเพิ่ม mapping ใน SiderLayout
  5. ถ้าเป็นหน้า detail (มี param :id) ให้ตั้ง isShow: false และครอบด้วย parent ที่ isShow: true

ตอนที่ผู้ใช้บอกว่า "เห็นเมนูที่ไม่ควรเห็น"

  • ตรวจ SiderLayout ว่า logic ที่ toggle isShow ตาม key ถูกต้องไหม
  • ตรวจ query getProfileByEmpID ว่า role flag ที่ได้ตรงกับที่คาดไหม
  • อย่า แก้ที่ฝั่ง frontend อย่างเดียว — ถ้าผู้ใช้เข้าถึงข้อมูลได้จริงแสดงว่าฝั่ง server ก็อนุญาตด้วย ต้องแก้ที่ @auth directive ใน vtrc-api

ตอนที่ผู้ใช้บอกว่า "พิมพ์ URL แล้วเข้าได้ทั้งที่ไม่มีเมนู"

  • นี่เป็นพฤติกรรมปกติของระบบ — route ทั้งหมดถูกสร้างเสมอ
  • ผู้ใช้อาจเข้าได้แต่จะเจอข้อมูลว่างเพราะ query ฝั่ง server จะ FORBIDDEN
  • ถ้าต้องการป้องกันจริง ให้เพิ่ม <PrivateRoute> ที่เช็ค role ก่อน render

ตอน debug "route 404"

  1. ตรวจว่า path ใน entry ตรงกับที่พิมพ์ (รวม case)
  2. ตรวจว่า component field ไม่เป็น undefined (import ผิด)
  3. ตรวจ exact — route ทุกตัวใช้ exact (ดู renderRoute) ถ้า path มี param (/:id) ต้องใส่ path parent ให้ครบ

ตอนเปลี่ยน label หรือ icon ของเมนู

แก้ใน routes.js ใน entry นั้น — SiderLayout จะใช้ค่าจาก routes.js ทั้งหมด ไม่มีการ hardcode ใน SiderLayout


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

ไป บท 6.5 GraphQL queries + mutations catalog