2 · API endpoint resolution + auth/login flow
บทนี้ตรวจว่า vtrc-backoffice เลือก GraphQL endpoint อย่างไร แนบ auth header อย่างไร และจัดการ login/token refresh อย่างไร
checkLinkURL() — ยืนยัน pattern เดียวกับ canonical-truths
vtrc-backoffice มี checkLinkURL() ที่อ่านจาก window.location.href เหมือนกับ vtrc-web/vtrc-rc-backoffice ตามที่ระบุใน canonical-truths — ไม่ใช้ NODE_ENV:
vtrc-backoffice/src/config/apollo.js:14-28
const checkLinkURL = () =>{
if(window.location.href.includes("https://vtrcbackoffice.redcross.or.th/")){
return "https://vtrcapi.redcross.or.th/api/graphql"
}else if(window.location.href.includes("https://uat-vtrcbackoffice.redcross.or.th/")){
// return "https://vtrcapi.redcross.or.th/api/graphql"
// return "https://vtrcapi.redcross.or.th/backup-site/graphql"
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
}else if(window.location.href.includes("http://localhost:3000/") || window.location.href.includes("http://localhost:3001/")){
// return "http://192.168.0.125:8081/api-uat/graphql"
// return "http://10.188.128.162:8081/api-uat/graphql"
// return "https://vtrcapi.redcross.or.th/api/graphql"
// return "https://vtrcapi.redcross.or.th/backup-site/graphql"
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
}
}การจับคู่ origin → endpoint
| Origin ที่เปิด | GraphQL endpoint |
|---|---|
vtrcbackoffice.redcross.or.th | vtrcapi.redcross.or.th/api/graphql (prod) |
uat-vtrcbackoffice.redcross.or.th | uat-vtrcapi.redcross.or.th/api-uat/graphql (UAT) |
localhost:3000 หรือ localhost:3001 | UAT (default fallback ตอน dev) |
| origin อื่น ๆ | ไม่มี branch ตรง → คืน undefined |
ข้อระวัง — เหมือน vtrc-rc-backoffice ทุกประการ
- ใช้
window.location.href.includes()เป็น substring match ไม่ parse origin แบบเข้มงวด - ไม่มี fallback
return— origin ที่ไม่ตรงเงื่อนไข (เช่น deploy ไป domain ใหม่โดยไม่แก้โค้ด) จะได้undefinedทำให้createUploadLink({ uri: undefined })ยิง request ไปที่ path เดิมของหน้าเว็บเอง (relative) ซึ่งจะ 404 - ไม่มี branch สำหรับ Firebase/
*.web.appเหมือนที่vtrc-rc-backofficeมี — repo นี้มีเพียง 3 origin ที่รองรับ (น้อยกว่าvtrc-rc-backofficeซึ่งมี 5+)
checkLinkURL() ถูกเรียกตรงตอนสร้าง client (module-level, ไม่ lazy):
vtrc-backoffice/src/config/apollo.js:30-31
const apolloFetch = createApolloFetch({ uri: checkLinkURL() })
const uploadLink = createUploadLink({ uri: checkLinkURL() })checkLinkURL() ตัวที่สอง — ใน utils/auth.js (เวสติเจียล)
มี checkLinkURL() อีกตัวหนึ่งซ้ำอยู่ใน src/utils/auth.js ซึ่งคืนค่า host เปล่า (ไม่ใช่ path GraphQL):
vtrc-backoffice/src/utils/auth.js:50-64
export const checkLinkURL = () =>{
if(window.location.href.includes("https://vtrcbackoffice.redcross.or.th/")){
return "https://vtrcapi.redcross.or.th/"
}else if(window.location.href.includes("https://uat-vtrcbackoffice.redcross.or.th/")){
// return "https://vtrcapi.redcross.or.th/api/graphql"
// return "https://vtrcapi.redcross.or.th/backup-site/graphql"
return "https://uat-vtrcapi.redcross.or.th/"
}else if(window.location.href.includes("http://localhost:3000/") || window.location.href.includes("http://localhost:3001/")){
// return "http://192.168.0.162:8081/api/graphql"
// return "http://10.188.128.162:8081/api-uat/graphql"
// return "https://vtrcapi.redcross.or.th/api/graphql"
// return "https://vtrcapi.redcross.or.th/backup-site/graphql"
return "https://uat-vtrcapi.redcross.or.th/"
}
}ไม่มีหลักฐานในรอบตรวจนี้ว่ามีจุด import ใช้งานฟังก์ชันนี้จริง (export ซ้ำชื่อกับตัวใน apollo.js แต่คนละไฟล์คนละ scope) — เข้าข่ายโค้ดค้าง ทำเครื่องหมาย UNVERIFIED ว่ามีการเรียกใช้จริงหรือไม่ ดู QUAL-BO-02
การแนบ header — authLink
vtrc-backoffice/src/config/apollo.js:40-57
const authLink = setContext((_, { headers }) => {
const { token } = getToken()
const API_DEVICE_KEY = apiKeyNews()
if (token) {
return {
headers: {
...headers,
Authorization: `Bearer ${token}`,
apiKey : API_DEVICE_KEY
}
}
}
return {
headers: {
apiKey : API_DEVICE_KEY
}
}
})apiKeyNews() (src/utils/apiKey.js) คืน device UUID ตาม origin เดียวกันกับ checkLinkURL():
vtrc-backoffice/src/utils/apiKey.js:1-15
export const apiKeyNews = () => {
if(window.location.href.includes("https://vtrcbackoffice.redcross.or.th/")){
return 'd1de47c0-07a8-11eb-adc1-0242ac120002'
}else if(window.location.href.includes("https://uat-vtrcbackoffice.redcross.or.th/")){
return 'd912dfb3-e2f1-4ff6-be88-29127fd32b60'
}else if(window.location.href.includes("http://localhost:3000/") || window.location.href.includes("http://192.168.0.105:3000/")){
return 'd912dfb3-e2f1-4ff6-be88-29127fd32b60'
}
}apiKey UUID ทั้ง prod และ UAT hardcode อยู่ใน bundle ฝั่ง client (สอดคล้องกับ pattern เดียวกันของ vtrc-rc-backoffice ที่ v1 บันทึกไว้) — ไม่ใช่ secret ระดับสูง (เป็น device key ไม่ใช่ auth secret) แต่ยังมองเห็นได้จาก DevTools ทุกครั้ง
Token storage — src/utils/auth.js
vtrc-backoffice/src/utils/auth.js:1-44
export const setToken = ({ accessToken, refreshToken }, isRemember) => {
if (isRemember === undefined) {
isRemember = localStorage.getItem('isRemember')
}
if (isRemember) {
localStorage.setItem('token', accessToken)
if(refreshToken){
localStorage.setItem('refreshToken', refreshToken)
}
localStorage.setItem('isRemember', 'true')
} else {
sessionStorage.setItem('token', accessToken)
if(refreshToken){
sessionStorage.setItem('refreshToken', refreshToken)
}
}
}
export const getToken = () => {
if (localStorage.getItem('isRemember')) {
return {
token: localStorage.getItem('token'),
refreshToken: localStorage.getItem('refreshToken')
}
} else {
return {
token: sessionStorage.getItem('token'),
refreshToken: sessionStorage.getItem('refreshToken')
}
}
}
export const isLoggedIn = () => {
if (localStorage.getItem('isRemember')) {
return !!localStorage.getItem('token') && !!localStorage.getItem('refreshToken')
} else {
return !!sessionStorage.getItem('token') && !!sessionStorage.getItem('refreshToken')
}
}
export const clearAllStorage = () => {
localStorage.clear()
sessionStorage.clear()
}โครงสร้างรองรับสองโหมด — จำ session ไว้ถาวร (localStorage) หรือเฉพาะ tab (sessionStorage) แยกด้วย flag isRemember
bug ที่พบ — isRemember hardcode เป็น 1 ที่จุดเรียกจริง (CORR-BO-02)
Login/index.js เรียก setToken พร้อม hardcode isRemember = 1 เสมอ โดยไม่สนใจค่าจาก UI:
vtrc-backoffice/src/pages/Login/index.js:45-82
onSubmit = async (password, remember) => {
try {
// ...
if (data) {
setToken(data.loginBackoffice, 1)
sessionStorage.removeItem('lsid')
sessionStorage.removeItem('organization')
sessionStorage.removeItem('reCaptchaCheck')
window.location.href = '/';พารามิเตอร์ remember ที่ส่งเข้ามาไม่ถูกใช้เลยในฟังก์ชัน และ LoginForm (component ที่ render ฟอร์ม) import Checkbox จาก antd แต่ ไม่ได้ render checkbox ใด ๆ ในฟอร์มจริง:
vtrc-backoffice/src/components/LoginForm/index.js:1-21
import { Form, Icon, Input, Button, Checkbox, Typography } from 'antd'
// ...
class LoginForm extends React.PureComponent {
handleSubmit = values => {
const { afterSubmit } = this.props
afterSubmit(values)
}
render() {
const { form, autoFocus, username, backlogin } = this.props
return (
<Form onFinish={this.handleSubmit} style={{ maxWidth: 600, width: 500 }}>ผลคือ ทุก login เก็บ token ลง localStorage เสมอ ไม่มีทางเลือก session-only แม้โค้ดจะออกแบบไว้ให้รองรับ — token คงอยู่ข้ามการปิดเบราว์เซอร์ตลอดไปจนกว่าจะ logout หรือ token invalidate ฝั่ง server เพิ่มพื้นผิวเสี่ยงต่อ XSS อ่าน token ระยะยาว ดู CORR-BO-02/SEC-BO-01 ใน 04-scorecard
Auth gate — App/index.js
Auth gate ใช้ isLoggedIn() (boolean, ตรวจแค่ token/refreshToken มีอยู่ใน storage หรือไม่ — ไม่ verify signature/expiry ฝั่ง client):
vtrc-backoffice/src/containers/App/index.js:13-26
function App() {
console.log = console.warn = console.error = () => { };
return (
<BrowserRouter>
<Switch>
<Route path="/login" exact component={Login} />
{isLoggedIn() && <Route path="/" component={SiderLayout} />}
{!isLoggedIn() && <Redirect to="/login" />}
<Route component={NotFound} />
</Switch>
</BrowserRouter>
)
}isLoggedIn() ถูกเรียกครั้งเดียวตอน render ของ App — ไม่ reactive กับการเปลี่ยนแปลง storage หลัง mount (เช่น token ถูก clear จาก tab อื่น) จนกว่าจะมี re-render หรือ navigate ใหม่ — เป็น pattern เดียวกับที่ v1 พบใน vtrc-rc-backoffice
Login flow เต็ม — pages/Login/index.js
- ผู้ใช้กรอกรหัสบุคลากร (username) → เรียก
onSubmitCheck(ตรวจ captcha/organization ก่อน — ดูโค้ดในsrc/pages/Login/index.js:108-สำหรับราละเอียด captcha) - กรอกรหัสผ่าน →
onSubmitเรียก mutationloginBackoffice:
vtrc-backoffice/src/pages/Login/index.js:45-61
const { data, errors } = await client.mutate({
mutation: Mutations.loginBackoffice,
variables: {
lsid: sessionStorage.getItem('lsid'),
password: password.password,
appVersion: "v1.0",
apiKey: API_DEVICE_KEY,
organization: getOrganization(),
}
})organizationเป็นค่าคงที่จากgetOrganization()(src/utils/auth.js:46-48) คืน UUID เดียวเสมอ ("3F3BF3AD-B4C9-4D44-A56F-AB55C4E4FB01-00") — ไม่ได้อ่านจาก input ผู้ใช้จริง แม้ตัวแปรsessionStorage.getItem('organization')ถูก comment ทิ้งไว้ในโค้ด (src/pages/Login/index.js:58-59)- สำเร็จ →
setToken(data.loginBackoffice, 1)→ เคลียร์lsid/organization/reCaptchaCheckจาก sessionStorage →window.location.href = '/'(full page reload ไม่ใช่ router push) - error
INTERNAL_SERVER_ERROR→ แสดงModalErrors; error message ตรงกับ "คุณสามารถใส่รหัสผ่านได้อีก 0 ครั้ง" (ล็อกบัญชี) → redirect ไปหน้าแรกทันที (ไม่ได้แจ้งเตือนผู้ใช้ก่อน — UX ที่น่าสงสัยแต่ไม่กระทบ security)
Token refresh — errorLink
เมื่อได้ extensions.code === 'TOKEN_EXPIRED' จะเรียก query refreshToken ผ่าน apolloFetch (ไม่ใช่ apolloClient.query):
vtrc-backoffice/src/config/apollo.js:59-90
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_EXPIRED') {
return new Observable(async observer => {
const oldHeaders = operation.getContext().headers
const { errors, data } = await apolloFetch({
query: Queries.refreshToken,
variables: {
refreshToken: getToken().refreshToken,
token: getToken().token
},
})
if (!errors) {
setToken(data.refreshToken)
operation.setContext({
headers: {
...oldHeaders,
Authorization: `Bearer ${data.refreshToken.accessToken}`
}
})
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
}
forward(operation).subscribe(subscriber)
} else if (errors && errors[0].extensions.code === 'TOKEN_INVALID') {
alertMessage("error","มีบางอย่างผิดพลาดกรุณาลองใหม่อีกครั้ง")
clearAllStorage()
window.location.href = '/login'
}
})
}จุดสำคัญ — setToken(data.refreshToken) เรียกโดยไม่ส่ง isRemember ทำให้ฟังก์ชันไปอ่าน localStorage.getItem('isRemember') เป็นค่า default (setToken บรรทัด 2-4 ของ auth.js) ซึ่งจะเป็น 'true' เสมอเพราะ login ครั้งแรก hardcode ไว้ (ดูหัวข้อ bug ด้านบน) — สอดคล้องกันเองในแง่ที่ไม่ error แต่สะท้อนว่าการออกแบบ session-only ไม่เคยถูกใช้งานจริงในโค้ด path ปัจจุบัน
รหัส error อื่นที่ errorLink จัดการ (TOKEN_INVALID, INTERNAL_SERVER_ERROR, SESSION_EXPIRED, SESSION_INVALID, CANNOT_ACCESS, AUTH_USER_OR_PASS_INVALID, AUTH_LIMIT, USER_OR_ORG_WRONG, DATA_USED, PROFILE_NOT_FOUND) — ตรงกันกับชุดที่ v1 พบใน vtrc-rc-backoffice (vtrc-backoffice/src/config/apollo.js:96-149)
fetchPolicy
vtrc-backoffice/src/config/apollo.js:154-168
const apolloClient = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, uploadLink]),
cache: new InMemoryCache()
})
apolloClient.defaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
errorPolicy: 'ignore'
},
query: {
fetchPolicy: 'no-cache',
errorPolicy: 'all'
}
}fetchPolicy: 'no-cache' เป็น default ทั้ง query และ watchQuery — เหมือนทุก frontend อื่นในกลุ่มนี้ (vtrc-web, vtrc-rc-backoffice) InMemoryCache ถูกสร้างแต่แทบไม่ถูกใช้อ่านซ้ำ
WorkForce axios client — endpoint ผิด (dead code)
src/config/axios-workforce.js มี unreachable code — บรรทัดที่สองไม่มีทางถูก execute:
vtrc-backoffice/src/config/axios-workforce.js:5-8
const checkLinkURL = () => {
return "http://localhost:8083/api/restful"
return 'https://uat-vtrcapi.redcross.or.th/workforce-api/api/restful'
}ผลคือถ้าไฟล์นี้ถูก import และเรียกใช้จริง ทุก request จะพยายามยิงไปที่ http://localhost:8083 จากเบราว์เซอร์ผู้ใช้ (ซึ่งจะ fail เสมอนอกเครื่อง dev) — full-text search ทั่ว src/ ไม่พบจุด import ไฟล์นี้ในโค้ดปัจจุบัน จึงยังไม่กระทบ production จริง (เป็นโค้ดค้างมี bug ซ่อน) ดู CORR-BO-01 ใน 04-scorecard