Skip to content

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

javascript
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.thvtrcapi.redcross.or.th/api/graphql (prod)
uat-vtrcbackoffice.redcross.or.thuat-vtrcapi.redcross.or.th/api-uat/graphql (UAT)
localhost:3000 หรือ localhost:3001UAT (default fallback ตอน dev)
origin อื่น ๆไม่มี branch ตรง → คืน undefined

ข้อระวัง — เหมือน vtrc-rc-backoffice ทุกประการ

  1. ใช้ window.location.href.includes() เป็น substring match ไม่ parse origin แบบเข้มงวด
  2. ไม่มี fallback return — origin ที่ไม่ตรงเงื่อนไข (เช่น deploy ไป domain ใหม่โดยไม่แก้โค้ด) จะได้ undefined ทำให้ createUploadLink({ uri: undefined }) ยิง request ไปที่ path เดิมของหน้าเว็บเอง (relative) ซึ่งจะ 404
  3. ไม่มี 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

javascript
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

javascript
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


vtrc-backoffice/src/config/apollo.js:40-57

javascript
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

javascript
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

javascript
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

javascript
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

javascript
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

javascript
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

  1. ผู้ใช้กรอกรหัสบุคลากร (username) → เรียก onSubmitCheck (ตรวจ captcha/organization ก่อน — ดูโค้ดใน src/pages/Login/index.js:108- สำหรับราละเอียด captcha)
  2. กรอกรหัสผ่าน → onSubmit เรียก mutation loginBackoffice:

vtrc-backoffice/src/pages/Login/index.js:45-61

javascript
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(),
	}
})
  1. 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)
  2. สำเร็จ → setToken(data.loginBackoffice, 1) → เคลียร์ lsid/organization/reCaptchaCheck จาก sessionStorage → window.location.href = '/' (full page reload ไม่ใช่ router push)
  3. error INTERNAL_SERVER_ERROR → แสดง ModalErrors; error message ตรงกับ "คุณสามารถใส่รหัสผ่านได้อีก 0 ครั้ง" (ล็อกบัญชี) → redirect ไปหน้าแรกทันที (ไม่ได้แจ้งเตือนผู้ใช้ก่อน — UX ที่น่าสงสัยแต่ไม่กระทบ security)

เมื่อได้ extensions.code === 'TOKEN_EXPIRED' จะเรียก query refreshToken ผ่าน apolloFetch (ไม่ใช่ apolloClient.query):

vtrc-backoffice/src/config/apollo.js:59-90

javascript
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

javascript
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

javascript
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


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

ไป 3 · Feature/module list