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
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 |
|---|---|---|
/login | public | Login |
/verify/user/A1, /verify/user/A2 | public | ConfirmUserA1, ConfirmUserA2 |
/forgetpassword | public | ForgetPassword |
/passwords/first/time | public | SetPasswordsFirstTime |
/ (ทุก path) | isLoggedIn() === true | SiderLayout (shell) |
| (default) | isLoggedIn() === false | redirect /login |
isLoggedIn() — auth gate ตัวเดียว
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> ภายใน
export const renderAllRoutes = () => (
<Switch>
{mapRoutes(routes)}
{renderDefaultRoute()}
</Switch>
);mapRoutes วน routes ทั้งหมดแบบ recursive แล้ว flatten ให้เป็น <Route> ต่าง ๆ:
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 เพิ่มเติม:
<Route
exact
key={basePath + route.path}
path={basePath + route.path}
render={(props) => <route.component {...props} {...route.props} />}
/>
);โครงสร้าง route entry
จาก comment ใน routes.js:145-153:
/* 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 จริง:
{
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,
},
],
},
...
],
},ฟิลด์ที่ใช้บ่อย
| ฟิลด์ | หน้าที่ |
|---|---|
key | id ที่ใช้เช็ค permission (เช่น isPmsEvaluate, isPmsIndicators) |
label | ข้อความบนเมนู |
icon | AntD icon name |
path | URL path |
isShow | ถ้า true แสดงบนเมนู ถ้า false ไม่แสดง แต่ route ยังเข้าได้ |
isShowSub | สำหรับ submenu (PMS) |
isShowSubmenuDynamic | dynamic show/hide ตาม role flag |
component | React component ที่จะ render |
children | nested route (recursive) |
Menu visibility — ไม่ใช่การคุมสิทธิ์
ข้อผิดพลาดที่พบบ่อยที่สุดในระบบคือ เข้าใจผิดว่า isShow: false หรือ key: "isPms..." เป็นการห้ามผู้ใช้เข้าถึง route
isShow เป็นเพียง flag ฝั่ง UI
isShow ใช้คุมว่าจะแสดงเมนูหรือไม่ — แต่ route ยังถูกสร้างโดย mapRoutes() ทั้งหมด ผู้ใช้ที่พิมพ์ URL ตรง ๆ จะเข้าได้เสมอ
ตัวอย่าง:
{
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 ขนาดใหญ่ที่:
- ดึง profile + role ของผู้ใช้ผ่าน
this.props.client.query({ query: Queries.getProfileByEmpID }) - วาดเมนูซ้าย + header + content area
- เรียก
renderAllRoutes()เพื่อใส่<Switch>ใน content area - คำนวณ
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:
| Cluster | Path 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 ใหม่
- import component ที่จะใช้ใน
routes.js - เพิ่ม entry ใน array
routes(เลือก cluster ที่เหมาะสม) - ตั้ง
isShow: trueถ้าต้องการให้แสดงบนเมนู - ถ้าเป็นหน้าที่ต้องคุมตาม role ให้ตั้ง
key: "isXXX"แล้วเพิ่ม mapping ใน SiderLayout - ถ้าเป็นหน้า detail (มี param
:id) ให้ตั้งisShow: falseและครอบด้วย parent ที่isShow: true
ตอนที่ผู้ใช้บอกว่า "เห็นเมนูที่ไม่ควรเห็น"
- ตรวจ SiderLayout ว่า logic ที่ toggle
isShowตามkeyถูกต้องไหม - ตรวจ query
getProfileByEmpIDว่า role flag ที่ได้ตรงกับที่คาดไหม - อย่า แก้ที่ฝั่ง frontend อย่างเดียว — ถ้าผู้ใช้เข้าถึงข้อมูลได้จริงแสดงว่าฝั่ง server ก็อนุญาตด้วย ต้องแก้ที่
@authdirective ใน vtrc-api
ตอนที่ผู้ใช้บอกว่า "พิมพ์ URL แล้วเข้าได้ทั้งที่ไม่มีเมนู"
- นี่เป็นพฤติกรรมปกติของระบบ — route ทั้งหมดถูกสร้างเสมอ
- ผู้ใช้อาจเข้าได้แต่จะเจอข้อมูลว่างเพราะ query ฝั่ง server จะ
FORBIDDEN - ถ้าต้องการป้องกันจริง ให้เพิ่ม
<PrivateRoute>ที่เช็ค role ก่อน render
ตอน debug "route 404"
- ตรวจว่า path ใน entry ตรงกับที่พิมพ์ (รวม case)
- ตรวจว่า
componentfield ไม่เป็นundefined(import ผิด) - ตรวจ
exact— route ทุกตัวใช้exact(ดูrenderRoute) ถ้า path มี param (/:id) ต้องใส่ path parent ให้ครบ
ตอนเปลี่ยน label หรือ icon ของเมนู
แก้ใน routes.js ใน entry นั้น — SiderLayout จะใช้ค่าจาก routes.js ทั้งหมด ไม่มีการ hardcode ใน SiderLayout