diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f974864..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,155 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is an HRMS (Human Resource Management System) check-in/check-out web application built for the Bangkok Metropolitan Administration (BMA). The application allows employees to record their work attendance using geolocation verification and camera features. - -**Tech Stack:** -- Vue 3 with Composition API and TypeScript -- Vite for build tooling -- Quasar Framework for UI components -- Pinia for state management -- Vue Router for routing -- Keycloak for authentication -- ArcGIS API and Google Maps for location features -- PWA capabilities with offline support -- Docker deployment with nginx - -**Development Server:** Runs on port 3008 - -## Common Commands - -```bash -# Development -npm run dev # Start dev server on port 3008 - -# Building -npm run build # Build for production -npm run preview # Preview production build on port 3008 - -# Testing -npm run test:unit # Run unit tests with vitest -npm run test:e2e # Run e2e tests with cypress -npm run test:e2e:dev # Run cypress in dev mode - -# Code Quality -npm run lint # Lint and auto-fix code -npm run format # Format code with prettier -npm run type-check # TypeScript type checking - -# Docker -docker buildx build --platform=linux/amd64 -f docker/Dockerfile . -t hrms-checkin:0.1 -``` - -## Architecture - -### Directory Structure - -``` -src/ -├── api/ # API layer modules (checkin, history, message) -├── components/ # Reusable Vue components -├── composables/ # Vue composables (e.g., usePermissions) -├── interface/ # TypeScript type definitions -│ └── response/ # API response types -├── plugins/ # Vue plugins (auth, http, keycloak) -├── router/ # Vue Router configuration -├── stores/ # Pinia stores for state management -├── style/ # Global styles and Quasar variables -├── utils/ # Utility functions -└── views/ # Page-level Vue components -``` - -### Key Architecture Patterns - -**Authentication Flow:** -- Uses Keycloak for SSO authentication -- Tokens stored in cookies with `BMAHRISCKI_KEYCLOAK_IDENTITY` key -- Auth state managed in `src/plugins/auth.ts` -- Router guards enforce authentication on protected routes -- 401 responses trigger automatic logout via axios interceptor - -**State Management:** -- `stores/mixin.ts` - Global utilities (Thai date formatting, dialogs, loaders, error handling) -- `stores/chekin.ts` - Check-in history and status management -- `stores/privacy.ts` - Privacy consent management -- `stores/positionKeycloak.ts` - User position/role data - -**API Layer:** -- Base axios instance in `src/plugins/http.ts` with automatic token injection -- API endpoints organized by feature in `src/api/` -- Environment-specific API URLs configured in `src/api/index.ts` -- Production API URL set via `VITE_API_URI_CONFIG` env variable - -**Routing:** -- Main layout (`MainView`) wraps authenticated routes -- Protected routes require `meta.Auth: true` -- Auth check runs in router beforeEach guard -- Public routes: `/login`, `/auth`, `/reset-password`, `/history` - -**Geolocation & Maps:** -- ArcGIS JS API for advanced mapping features -- Google Maps integration for location services -- Camera integration for check-in photo verification -- Privacy consent required before accessing camera/location - -**Date/Time Handling:** -- Thai Buddhist calendar (BE) conversion (+543 years) -- Thai month names (full and abbreviated) -- `date-fns-tz` for timezone handling (Asia/Bangkok) -- Fiscal year calculation (October start) - -### Component Conventions - -- Components use Quasar UI components (`q-btn`, `q-dialog`, etc.) -- Thai language throughout the UI -- Custom dialogs use `CustomDialog.vue` component -- Loading states use Quasar's `QSpinnerCube` -- Error dialogs prevent overlap with `activeErrorDialog` tracking - -### Localization - -- Quasar configured with Thai language (`quasar/lang/th`) -- All UI text in Thai -- Date formatting uses `date2Thai()` and `monthYear2Thai()` from mixin store -- Status labels translated: ABSENT→'ขาดราชการ', NORMAL→'ปกติ', LATE→'สาย' - -### Environment Variables - -Required in `.env.production`: -- `VITE_API_URI_CONFIG` - Production API base URL -- `VITE_URL_SSO` - Keycloak SSO logout URL -- `VITE_URL_USER` - User service URL for redirects - -### PWA Configuration - -- Auto-update registration with `vite-plugin-pwa` -- Manifest: "HRMS-Checkin" / "HRMS Checkin" -- Icons: 192x192 and 512x512 PNG -- Service worker registered in `src/registerServiceWorker.ts` - -## Development Notes - -**When adding new features:** -1. Create API functions in `src/api/` -2. Define TypeScript interfaces in `src/interface/response/` -3. Use Pinia stores for complex state, composables for reusable logic -4. Follow Thai localization patterns for user-facing text -5. Ensure privacy consent flow for camera/location features - -**When working with dates:** -- Always use `date2Thai()` or `convertDateToAPI()` from mixin store -- Dates stored in BE format (year + 543) -- API expects `yyyy-MM-dd` or `yyyy-MM-dd HH:mm:ss` format - -**When handling errors:** -- Use `messageError()` from mixin store for consistent error dialogs -- 401 errors trigger automatic logout -- Dialog overlap prevention built into error handling - -**When running tests:** -- Unit tests use Vitest with jsdom environment -- E2E tests use Cypress with baseUrl: `http://localhost:4173` -- Test files follow pattern: `*.cy.{js,ts}` or `*.spec.{js,ts}` diff --git a/src/components/AscGISMap.vue b/src/components/AscGISMap.vue index 7651b7c..7ed48a7 100644 --- a/src/components/AscGISMap.vue +++ b/src/components/AscGISMap.vue @@ -5,20 +5,51 @@ import axios from 'axios' import { useCounterMixin } from '@/stores/mixin' import { useQuasar } from 'quasar' import { usePrivacyStore } from '@/stores/privacy' +import { useLocationValidation } from '@/composables/useLocationValidation' const mixin = useCounterMixin() const { messageError } = mixin const privacyStore = usePrivacyStore() -const emit = defineEmits(['update:location']) +// import type { LocationObject } from '@/interface/index/Main' +const mapElement = ref(null) +const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected']) const $q = useQuasar() +const { validateLocation } = useLocationValidation() +const MOCK_CHECK_DELAY_MS = 800 + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function getCurrentPositionAsync(options?: PositionOptions) { + return new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, options) + }) +} + +async function getPositionForValidation(fallback: GeolocationPosition) { + await wait(MOCK_CHECK_DELAY_MS) + try { + return await getCurrentPositionAsync({ + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + }) + } catch (error) { + // Keep the original reading if the second sampling fails. + return fallback + } +} + function updateLocation(latitude: number, longitude: number, namePOI: string) { // ส่ง event ไปยัง parent component เพื่ออัพเดทค่า props emit('update:location', latitude, longitude, namePOI) } const poiPlaceName = ref('') // ชื่อพื้นที่ใกล้เคียง +const isMapReady = ref(false) // Replace ArcGIS api key const apiKey = ref( @@ -27,7 +58,7 @@ const apiKey = ref( ) const zoomMap = ref(18) -async function initializeMap() { +async function initializeMap(position: GeolocationPosition) { try { // Load modules of ArcGIS loadModules([ @@ -38,159 +69,149 @@ async function initializeMap() { 'esri/Graphic', 'esri/layers/TileLayer', ]).then(async ([esriConfig, Map, MapView, Point, Graphic, TileLayer]) => { - // Set apiKey - // esriConfig.apiKey = - // 'AAPK4f700a4324d04e9f8a1a134e0771ac45FXWawdCl-OotFfr52gz9XKxTDJTpDzw_YYcwbmKDDyAJswf14FoPyw0qBkN64DvP' - - // Create a FeatureLayer using a custom server URL - // const hillshadeLayer = new TileLayer({ - // url: `https://bmagis.bangkok.go.th/arcgis/rest/services/cache/BMA_3D_2D_Cache/MapServer`, - // }) - const map = new Map({ basemap: 'streets', - // basemap: 'arcgis-topographic', - // layers: [hillshadeLayer], }) - navigator.geolocation.getCurrentPosition(async (position) => { - const { latitude, longitude } = position.coords + const { latitude, longitude } = position.coords - const mapView = new MapView({ - container: 'mapViewDisplay', - map: map, - center: { - latitude: latitude, - longitude: longitude, - }, // Set the initial map center current position + const mapView = new MapView({ + container: 'mapViewDisplay', + map: map, + center: { + latitude: latitude, + longitude: longitude, + }, // Set the initial map center current position - zoom: zoomMap.value, - constraints: { - snapToZoom: false, // Disables snapping to the zoom level - minZoom: zoomMap.value, // Set minimum zoom level - maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom) - }, + zoom: zoomMap.value, + constraints: { + snapToZoom: false, // Disables snapping to the zoom level + minZoom: zoomMap.value, // Set minimum zoom level + maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom) + }, - ui: { - components: [], // Empty array to remove all default UI components - }, - }) + ui: { + components: [], // Empty array to remove all default UI components + }, + }) + isMapReady.value = true - // ตำแหน่งของผู้ใช้ - const userPoint = new Point({ longitude, latitude }) - const userSymbol = { - type: 'picture-marker', - url: 'http://maps.google.com/mapfiles/ms/icons/red.png', - width: '32px', - height: '32px', - } - const userGraphic = new Graphic({ - geometry: userPoint, - symbol: userSymbol, - }) - mapView.graphics.add(userGraphic) - // Get POI place ยิงไปขอที่ server ของกทม.ก่อน - await axios - .get( - 'https://bmagis.bangkok.go.th/portal/sharing/servers/e4732c3a9fe549ab8bc697573b468f68/rest/services/World/GeocodeServer/reverseGeocode/', - { - params: { - f: 'json', // Format JSON response - distance: 2000, - category: 'POI', - location: { - spatialReference: { wkid: 4326 }, - x: longitude, - y: latitude, - }, - token: apiKey.value, + // ตำแหน่งของผู้ใช้ + const userPoint = new Point({ longitude, latitude }) + const userSymbol = { + type: 'picture-marker', + url: 'http://maps.google.com/mapfiles/ms/icons/red.png', + width: '32px', + height: '32px', + } + const userGraphic = new Graphic({ + geometry: userPoint, + symbol: userSymbol, + }) + mapView.graphics.add(userGraphic) + // Get POI place ยิงไปขอที่ server ของกทม.ก่อน + await axios + .get( + 'https://bmagis.bangkok.go.th/portal/sharing/servers/e4732c3a9fe549ab8bc697573b468f68/rest/services/World/GeocodeServer/reverseGeocode/', + { + params: { + f: 'json', // Format JSON response + distance: 2000, + category: 'POI', + location: { + spatialReference: { wkid: 4326 }, + x: longitude, + y: latitude, }, - } - ) - .then((response) => { - // console.log('poi', response.data.location) - poiPlaceName.value = response.data.address - ? response.data.address.PlaceName === '' - ? response.data.address.ShortLabel - : response.data.address.PlaceName - : 'ไม่พบข้อมูล' - const poiPoint = new Point({ - longitude: response.data.location.x, - latitude: response.data.location.y, - }) - const poiSymbol = { - type: 'picture-marker', - url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', - width: '32px', - height: '32px', - } - const poiGraphic = new Graphic({ - geometry: poiPoint, - symbol: poiSymbol, - }) - mapView.graphics.add(poiGraphic) - // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI - mapView.goTo({ - target: [userPoint, poiPoint], - zoom: zoomMap.value, - }) - - updateLocation(latitude, longitude, poiPlaceName.value) + token: apiKey.value, + }, + } + ) + .then((response) => { + // console.log('poi', response.data.location) + poiPlaceName.value = response.data.address + ? response.data.address.PlaceName === '' + ? response.data.address.ShortLabel + : response.data.address.PlaceName + : 'ไม่พบข้อมูล' + const poiPoint = new Point({ + longitude: response.data.location.x, + latitude: response.data.location.y, }) - .catch(async (error) => { - // console.error('Error fetching points of interest:', error) - // Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token - await axios - .get( - 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode/', - { - params: { - f: 'json', // Format JSON response - distance: 2000, - category: 'POI', - location: { - spatialReference: { wkid: 4326 }, - x: longitude, - y: latitude, - }, + const poiSymbol = { + type: 'picture-marker', + url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', + width: '32px', + height: '32px', + } + const poiGraphic = new Graphic({ + geometry: poiPoint, + symbol: poiSymbol, + }) + mapView.graphics.add(poiGraphic) + // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI + mapView.goTo({ + target: [userPoint, poiPoint], + zoom: zoomMap.value, + }) + + updateLocation(latitude, longitude, poiPlaceName.value) + }) + .catch(async (error) => { + // console.error('Error fetching points of interest:', error) + // Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token + await axios + .get( + 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode/', + { + params: { + f: 'json', // Format JSON response + distance: 2000, + category: 'POI', + location: { + spatialReference: { wkid: 4326 }, + x: longitude, + y: latitude, }, - } - ) - .then((response) => { - // console.log('poi', response.data.location) - poiPlaceName.value = response.data.address - ? response.data.address.PlaceName === '' - ? response.data.address.ShortLabel - : response.data.address.PlaceName - : 'ไม่พบข้อมูล' - const poiPoint = new Point({ - longitude: response.data.location.x, - latitude: response.data.location.y, - }) - const poiSymbol = { - type: 'picture-marker', - url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', - width: '32px', - height: '32px', - } - const poiGraphic = new Graphic({ - geometry: poiPoint, - symbol: poiSymbol, - }) - mapView.graphics.add(poiGraphic) - // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI - mapView.goTo({ - target: [userPoint, poiPoint], - zoom: zoomMap.value, - }) + }, + } + ) + .then((response) => { + // console.log('poi', response.data.location) + poiPlaceName.value = response.data.address + ? response.data.address.PlaceName === '' + ? response.data.address.ShortLabel + : response.data.address.PlaceName + : 'ไม่พบข้อมูล' + const poiPoint = new Point({ + longitude: response.data.location.x, + latitude: response.data.location.y, + }) + const poiSymbol = { + type: 'picture-marker', + url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', + width: '32px', + height: '32px', + } + const poiGraphic = new Graphic({ + geometry: poiPoint, + symbol: poiSymbol, + }) + mapView.graphics.add(poiGraphic) + // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI + mapView.goTo({ + target: [userPoint, poiPoint], + zoom: zoomMap.value, + }) - updateLocation(latitude, longitude, poiPlaceName.value) - }) - .catch((error) => { - // console.error('Error fetching points of interest:', error) - }) - }) - }) + updateLocation(latitude, longitude, poiPlaceName.value) + }) + .catch((error) => { + // Keep map visible even when POI lookup fails. + poiPlaceName.value = 'ไม่พบข้อมูล' + updateLocation(latitude, longitude, poiPlaceName.value) + }) + }) }) } catch (error) { console.error('Error loading the map', error) @@ -221,10 +242,35 @@ const requestLocationPermission = () => { navigator.geolocation.getCurrentPosition( async (position) => { - // Permission granted - locationGranted.value = true + const sampledPosition = await getPositionForValidation(position) - const { latitude, longitude } = position.coords + // Validate location first + const validationResult = validateLocation(sampledPosition) + + // Do not block map preview on initial mock detection. + // The hard-stop warning is enforced at submit time in HomeView. + if (validationResult.isMockDetected) { + locationGranted.value = true + emit('locationStatus', true) + } + + // Check for critical errors (invalid coordinates) that prevent showing location + const hasCriticalErrors = validationResult.errors.some((error) => + error.includes('พิกัดตำแหน่งไม่ถูกต้อง') + ) + + if (hasCriticalErrors) { + locationGranted.value = false + emit('locationStatus', false) + messageError($q, '', validationResult.errors[0]) + return + } + + // Permission granted for map preview state. + locationGranted.value = true + emit('locationStatus', true) + + const { latitude, longitude } = sampledPosition.coords // console.log('Current position:', latitude, longitude) if (!latitude || !longitude) { @@ -234,12 +280,13 @@ const requestLocationPermission = () => { // Center map on user's location if map is initialized if (privacyStore.isAccepted) { - await initializeMap() + await initializeMap(sampledPosition) } }, (error) => { // Permission denied locationGranted.value = false + emit('locationStatus', false) switch (error.code) { case error.PERMISSION_DENIED: @@ -264,18 +311,19 @@ const requestLocationPermission = () => { break } }, - { enableHighAccuracy: true } + { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } ) } defineExpose({ requestLocationPermission, + locationGranted, }) diff --git a/src/components/DialogDebug.vue b/src/components/DialogDebug.vue index 798ce47..5fa1f3f 100644 --- a/src/components/DialogDebug.vue +++ b/src/components/DialogDebug.vue @@ -11,7 +11,6 @@ import http from '@/plugins/http' import config from '@/app.config' import DialogHeader from '@/components/DialogHeader.vue' -import FooterContact from '@/components/FooterContact.vue' const $q = useQuasar() const store = usePositionKeycloakStore() @@ -176,7 +175,7 @@ function onClose() { - - - - diff --git a/src/components/PopupPrivacy.vue b/src/components/PopupPrivacy.vue index 4493fab..a72dde3 100644 --- a/src/components/PopupPrivacy.vue +++ b/src/components/PopupPrivacy.vue @@ -108,7 +108,6 @@ const checkIfScrollable = () => { transition-show="slide-up" transition-hide="slide-down" :maximized="$q.screen.lt.sm" - @show="checkIfScrollable" > diff --git a/src/composables/useLocationValidation.ts b/src/composables/useLocationValidation.ts new file mode 100644 index 0000000..689af41 --- /dev/null +++ b/src/composables/useLocationValidation.ts @@ -0,0 +1,255 @@ +import { ref } from 'vue' +import { useQuasar } from 'quasar' +import { useCounterMixin } from '@/stores/mixin' + +export interface LocationValidationResult { + isValid: boolean + isMockDetected: boolean + confidence: 'low' | 'medium' | 'high' + warnings: string[] + errors: string[] +} + +export interface PositionSnapshot { + latitude: number + longitude: number + timestamp: number +} + +// Configuration constants - exported for documentation and testing purposes +export const VALIDATION_CONFIG = { + MAX_TIMESTAMP_AGE_MS: 60_000, // 60 seconds - maximum acceptable age of location data + MAX_ACCURACY_METERS: 100, // 100 meters - maximum acceptable GPS accuracy + MAX_SPEED_MS: 100, // 100 m/s (~360 km/h) - maximum plausible movement speed + POSITION_HISTORY_SIZE: 5, // number of positions to keep for pattern detection + MOCK_INDICATOR_THRESHOLD: 3, // threshold for mock detection (indicators >= 3 = mock) + SUSPICIOUS_ACCURACY_MAX: 5, // accuracy ≤ 5m AND integer = suspicious (real GPS never rounds to whole number) + // Geographic service area (Thailand) to reduce spoofing risk from remote fake coordinates. + SERVICE_AREA: { + MIN_LAT: 5.0, + MAX_LAT: 21.0, + MIN_LON: 97.0, + MAX_LON: 106.0, + }, +} as const + +export function useLocationValidation() { + const $q = useQuasar() + const { messageError } = useCounterMixin() + + // Thai error messages - exported for i18n consistency + const errorMessages = { + MOCK_DETECTED: + 'ตรวจพบว่าตำแหน่ง GPS อาจไม่ถูกต้อง กรุณาปิดแอปจำลองตำแหน่งและลองใหม่', + INVALID_COORDINATES: 'พิกัดตำแหน่งไม่ถูกต้อง กรุณาลองใหม่', + STALE_TIMESTAMP: 'ข้อมูลตำแหน่งเก่าเกินไป กรุณารับสัญญาณ GPS ใหม่', + POOR_ACCURACY: 'ความแม่นยำตำแหน่งต่ำเกินไป กรุณาตรวจสอบการรับสัญญาณ GPS', + IMPOSSIBLE_SPEED: + 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง', + SUSPICIOUS_ACCURACY: 'ตรวจพบค่าความแม่นยำที่ผิดปกติ อาจเป็นการจำลองตำแหน่ง', + DUPLICATE_POSITION: 'ตรวจพบพิกัดตำแหน่งซ้ำกัน อาจเป็นการจำลองตำแหน่ง', + OUT_OF_SERVICE_AREA: + 'ตรวจพบตำแหน่งนอกพื้นที่ใช้งานของระบบ กรุณาปิดแอปจำลองตำแหน่งและลองใหม่', + } + + const previousPositions = ref([]) + + // คำนวณระยะห่างระหว่าง 2 จุด (Haversine formula) + const haversineDistance = ( + lat1: number, + lon1: number, + lat2: number, + lon2: number + ): number => { + const R = 6371e3 // Earth's radius in meters + const φ1 = (lat1 * Math.PI) / 180 + const φ2 = (lat2 * Math.PI) / 180 + const Δφ = ((lat2 - lat1) * Math.PI) / 180 + const Δλ = ((lon2 - lon1) * Math.PI) / 180 + + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2) + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + return R * c + } + + // ตรวจสอบพิกัดถูกต้อง + const validateCoordinates = (lat: number, lon: number): boolean => { + return ( + lat >= -90 && + lat <= 90 && + lon >= -180 && + lon <= 180 && + !isNaN(lat) && + !isNaN(lon) && + !(lat === 0 && lon === 0) // Mock มักใช้ 0,0 + ) + } + + // ตรวจสอบความแม่นยำ + const validateAccuracy = (accuracy: number | null): boolean => { + if (accuracy === null) return true + return accuracy <= VALIDATION_CONFIG.MAX_ACCURACY_METERS + } + + // ตรวจสอบ Timestamp + const validateTimestamp = (timestamp: number): boolean => { + const now = Date.now() + const age = Math.abs(now - timestamp) + return age <= VALIDATION_CONFIG.MAX_TIMESTAMP_AGE_MS + } + + // คำนวณความเร็ว + const calculateSpeed = ( + pos1: PositionSnapshot, + pos2: PositionSnapshot + ): number => { + const distance = haversineDistance( + pos1.latitude, + pos1.longitude, + pos2.latitude, + pos2.longitude + ) + const timeDiff = Math.abs(pos2.timestamp - pos1.timestamp) / 1000 // seconds + return timeDiff > 0 ? distance / timeDiff : 0 + } + + // ตรวจสอบความเร็วปกติ + const validateSpeed = ( + current: PositionSnapshot, + previous: PositionSnapshot + ): boolean => { + const speed = calculateSpeed(previous, current) + return speed <= VALIDATION_CONFIG.MAX_SPEED_MS + } + + // ตรวจสอบค่าความแม่นยำที่ผิดปกติ (mock apps มักรายงานค่าที่เป็นเลขจำนวนเต็มสวยงาม เช่น 5.0, 3.0) + const hasSuspiciousPerfectAccuracy = (accuracy: number | null): boolean => { + if (accuracy === null) return false + return ( + Number.isInteger(accuracy) && + accuracy <= VALIDATION_CONFIG.SUSPICIOUS_ACCURACY_MAX + ) + } + + // ตรวจสอบพิกัดซ้ำกันทุกค่า (real GPS มีการสั่นเล็กน้อย mock app มักคืนพิกัดเดิมซ้ำ) + const hasDuplicateCoordinates = (lat: number, lon: number): boolean => { + return previousPositions.value.some( + (p) => p.latitude === lat && p.longitude === lon + ) + } + + const isWithinServiceArea = (lat: number, lon: number): boolean => { + const area = VALIDATION_CONFIG.SERVICE_AREA + return ( + lat >= area.MIN_LAT && + lat <= area.MAX_LAT && + lon >= area.MIN_LON && + lon <= area.MAX_LON + ) + } + + // Main validation function + const validateLocation = ( + position: GeolocationPosition + ): LocationValidationResult => { + const warnings: string[] = [] + const errors: string[] = [] + let mockIndicators = 0 + + const { latitude, longitude, accuracy } = position.coords + const { timestamp } = position + + // 1. Coordinate validation + if (!validateCoordinates(latitude, longitude)) { + errors.push(errorMessages.INVALID_COORDINATES) + mockIndicators += 3 + } + + // 1.1 Service-area validation (critical for remote spoofed coordinates) + if (!isWithinServiceArea(latitude, longitude)) { + errors.push(errorMessages.OUT_OF_SERVICE_AREA) + mockIndicators += 3 + } + + // 2. Timestamp validation + if (!validateTimestamp(timestamp)) { + errors.push(errorMessages.STALE_TIMESTAMP) + mockIndicators += 2 + } + + // 3. Accuracy validation + if (!validateAccuracy(accuracy)) { + warnings.push(errorMessages.POOR_ACCURACY) + mockIndicators += 1 + } + + // 4. Compare with previous positions + if (previousPositions.value.length > 0) { + const previous = + previousPositions.value[previousPositions.value.length - 1] + + if (!validateSpeed({ latitude, longitude, timestamp }, previous)) { + errors.push(errorMessages.IMPOSSIBLE_SPEED) + mockIndicators += 3 + } + } + + // 5. Suspiciously perfect accuracy — real GPS never rounds to a whole-number ≤ 5m + if (hasSuspiciousPerfectAccuracy(accuracy)) { + warnings.push(errorMessages.SUSPICIOUS_ACCURACY) + mockIndicators += 2 + } + + // 6. Exact duplicate coordinates — real GPS always drifts slightly between readings + if ( + previousPositions.value.length > 0 && + hasDuplicateCoordinates(latitude, longitude) + ) { + warnings.push(errorMessages.DUPLICATE_POSITION) + mockIndicators += 2 + } + + // Store current position + previousPositions.value.push({ latitude, longitude, timestamp }) + if ( + previousPositions.value.length > VALIDATION_CONFIG.POSITION_HISTORY_SIZE + ) { + previousPositions.value.shift() + } + + // Determine result + const isMockDetected = + mockIndicators >= VALIDATION_CONFIG.MOCK_INDICATOR_THRESHOLD + const isValid = errors.length === 0 + + let confidence: 'low' | 'medium' | 'high' = 'low' + if (mockIndicators >= 5) confidence = 'high' + else if (mockIndicators >= 3) confidence = 'medium' + + return { + isValid, + isMockDetected, + confidence, + warnings, + errors, + } + } + + const showMockWarning = (result: LocationValidationResult) => { + if (!result.isMockDetected) return + messageError($q, '', errorMessages.MOCK_DETECTED) + } + + const resetValidation = () => { + previousPositions.value = [] + } + + return { + validateLocation, + showMockWarning, + resetValidation, + } +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 5df3c0c..7021e65 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -8,6 +8,7 @@ import config from '@/app.config' import http from '@/plugins/http' import { useCounterMixin } from '@/stores/mixin' import { usePermissions } from '@/composables/usePermissions' +import { useLocationValidation } from '@/composables/useLocationValidation' import { usePrivacyStore } from '@/stores/privacy' import type { FormRef, OptionReason } from '@/interface/response/checkin' @@ -18,6 +19,8 @@ const mixin = useCounterMixin() const { date2Thai, showLoader, hideLoader, messageError, dialogConfirm } = mixin const $q = useQuasar() const { checkPrivacyAccepted } = usePermissions() +const { validateLocation, showMockWarning, resetValidation } = + useLocationValidation() const privacyStore = usePrivacyStore() const MOCK_CHECK_DELAY_MS = 800 @@ -33,6 +36,8 @@ const endTimeAfternoon = ref('12:00:00') //เวลาเช็คเ const isLoadingCheckTime = ref(false) // ตัวแปรสำหรับการโหลด const disabledBtn = ref(false) +const locationGranted = ref(false) +const isMockLocationDetected = ref(false) /** * fetch เช็คเวลาต้องลงเวลาเข้าหรือออกงาน @@ -114,7 +119,26 @@ async function updateLocation( formLocation.POI = namePOI } +/** + * รับค่าสถานะ location จาก AscGISMap + */ +function onLocationStatus(status: boolean) { + locationGranted.value = status + if (status) { + isMockLocationDetected.value = false + } +} + +/** + * รับค่า mock location detection จาก AscGISMap + */ +function onMockDetected(result: any) { + isMockLocationDetected.value = !!result?.isMockDetected + disabledBtn.value = false +} + function resetLocationForRetry() { + locationGranted.value = false formLocation.lat = 0 formLocation.lng = 0 formLocation.POI = '' @@ -150,6 +174,62 @@ async function getDelayedFreshPosition() { } } +async function revalidateLocationBeforeSubmit() { + if (!navigator.geolocation) { + messageError( + $q, + '', + 'ไม่สามารถระบุตำแหน่งปัจจุบันได้ เบราว์เซอร์ของคุณไม่รองรับ Geolocation' + ) + return false + } + + try { + // If previous attempt was mock, clear history so fresh GPS is not compared + // against spoofed coordinates and incorrectly flagged as impossible speed. + if (isMockLocationDetected.value) { + resetValidation() + } + + const position = await getDelayedFreshPosition() + + const validationResult = validateLocation(position) + + if (validationResult.isMockDetected) { + isMockLocationDetected.value = true + disabledBtn.value = false + resetValidation() + resetLocationForRetry() + showMockWarning(validationResult) + mapRef.value?.requestLocationPermission() + return false + } + + if (validationResult.errors.length > 0) { + disabledBtn.value = false + resetValidation() + resetLocationForRetry() + messageError($q, '', validationResult.errors[0]) + mapRef.value?.requestLocationPermission() + return false + } + + locationGranted.value = true + isMockLocationDetected.value = false + return true + } catch (error) { + disabledBtn.value = false + resetLocationForRetry() + messageError( + $q, + '', + 'ไม่สามารถตรวจสอบตำแหน่งล่าสุดก่อนลงเวลาได้ กรุณาลองใหม่อีกครั้ง' + ) + mapRef.value?.requestLocationPermission() + return false + } +} + const location = ref('') // พื้นที่ใกล้เคียง const model = ref('') // สถานที่ทำงาน // ตัวเลือกสถานที่ทำงาน @@ -187,9 +267,6 @@ const cameraIsOn = ref(false) const img = ref(undefined) const photoWidth = ref(350) const photoHeight = ref(350) -const availableCameras = ref([]) -const currentCameraIndex = ref(0) -const currentCameraType = ref<'front' | 'back' | 'unknown'>('unknown') const intervalId = ref(undefined) // ต้องใช้ตัวแปรเก็บค่า interval @@ -305,16 +382,6 @@ async function stopChecking() { } } -function identifyCameraType(label: string): 'front' | 'back' | 'unknown' { - const lowerLabel = label.toLowerCase() - if (lowerLabel.includes('front') || lowerLabel.includes('user') || lowerLabel.includes('face')) { - return 'front' - } else if (lowerLabel.includes('back') || lowerLabel.includes('environment') || lowerLabel.includes('rear')) { - return 'back' - } - return 'unknown' -} - /** function เปิดกล้อง*/ async function openCamera() { // เช็คสิทธิ์ privacy ก่อนเปิดกล้อง @@ -328,11 +395,7 @@ async function openCamera() { await camera.value?.stop() } else { await camera.value?.start() - const devices: any = await camera.value?.devices(['videoinput']) - if (devices) { - availableCameras.value = devices - await changeCamera() - } + await changeCamera() // ต้องรอให้ start() เสร็จก่อน } cameraIsOn.value = !cameraIsOn.value } else { @@ -346,59 +409,10 @@ async function openCamera() { } /** change camera device*/ -async function changeCamera(targetCameraType?: 'front' | 'back') { - try { - const devices: any = await camera.value?.devices(['videoinput']) - - if (!devices || devices.length === 0) { - console.warn('No cameras found') - return - } - - availableCameras.value = devices - - if (devices.length === 1 || !targetCameraType) { - const device = devices[0] - await camera.value?.changeCamera(device.deviceId) - currentCameraIndex.value = 0 - currentCameraType.value = identifyCameraType(device.label || '') - return - } - - const matchingCameras = devices.filter((device: any) => - identifyCameraType(device.label || '') === targetCameraType - ) - - if (matchingCameras.length > 0) { - const targetDevice = matchingCameras[0] - await camera.value?.changeCamera(targetDevice.deviceId) - currentCameraIndex.value = devices.indexOf(targetDevice) - currentCameraType.value = targetCameraType - } else { - const nextIndex = (currentCameraIndex.value + 1) % devices.length - const nextDevice = devices[nextIndex] - await camera.value?.changeCamera(nextDevice.deviceId) - currentCameraIndex.value = nextIndex - currentCameraType.value = identifyCameraType(nextDevice.label || '') - } - } catch (error) { - console.error('Error switching camera:', error) - } -} - -/** switch camera device*/ -async function switchCamera() { - if (availableCameras.value.length <= 1) { - return - } - - // สลับแค่ระหว่างกล้อง 2 ตัวแรก (กล้องหน้าและหลังหลัก) - const targetIndex = currentCameraIndex.value === 0 ? 1 : 0 - const targetDevice = availableCameras.value[targetIndex] - - await camera.value?.changeCamera(targetDevice.deviceId) - currentCameraIndex.value = targetIndex - currentCameraType.value = identifyCameraType(targetDevice.label || '') +async function changeCamera() { + const devices: any = await camera.value?.devices(['videoinput']) + const device = await devices[0] + camera.value?.changeCamera(device.deviceId) } /** function ถ่ายรูป*/ @@ -448,6 +462,11 @@ async function validateForm() { } } if (hasError.every((result) => result === true)) { + const isLocationValid = await revalidateLocationBeforeSubmit() + if (!isLocationValid) { + return + } + if (statusCheckin.value == false) { getCheck() } else if (statusCheckin.value) { @@ -492,6 +511,11 @@ async function confirm() { return } + const isLocationValid = await revalidateLocationBeforeSubmit() + if (!isLocationValid) { + return + } + showLoader() const isLocation = workplace.value === 'in-place' //*true คือ ณ สถานที่ตั้ง, false คือ นอกสถานที่ตั้ง const locationName = workplace.value === 'in-place' ? '' : useLocation.value @@ -710,6 +734,16 @@ watch( } } ) + +watch( + () => locationGranted.value, + (newVal) => { + // Removed auto-reset of isMockLocationDetected to prevent + // clearing mock detection state when permission is granted. + // Mock detection state should only be reset after explicit user action + // or after a successful validation without mock indicators. + } +)