Initial commit
@@ -0,0 +1,9 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1,4 @@
|
||||
android/app/build
|
||||
android/app/src/main/assets
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.env
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.env
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Use Node.js 22 Alpine as the base image
|
||||
FROM node:lts-alpine3.21
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
|
||||
# Install a simple HTTP server for serving static content
|
||||
RUN npm install -g http-server
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
# Start the application
|
||||
CMD ["node", "backend/server.js"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# NiLai-Clock
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22.14.0
|
||||
|
||||
## Customize configuration
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Database Configuration
|
||||
|
||||
Create a .env file in the project root with your database configuration:
|
||||
|
||||
```
|
||||
|
||||
DB_HOST=your_database_host
|
||||
DB_USER=your_database_user
|
||||
DB_PASSWORD=your_database_password
|
||||
DB_NAME=your_database_name
|
||||
DB_PORT=your_database_port
|
||||
VITE_API_BASE_URL=your_api_base_url
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
#### Run Frontend Only
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### Run Backend Only
|
||||
|
||||
```sh
|
||||
npm run backend
|
||||
```
|
||||
|
||||
#### Run Full Application (Frontend + Backend)
|
||||
|
||||
```sh
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
@@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -0,0 +1,55 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "com.ouji.factory.myapp"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.ouji.factory.myapp"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
dirs '../../node_modules/@capacitor/background-runner/android/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-background-geolocation')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-app-launcher')
|
||||
implementation project(':capacitor-device')
|
||||
implementation project(':capacitor-geolocation')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
|
||||
<!-- ADD THIS SERVICE DECLARATION -->
|
||||
<service
|
||||
android:name="com.capacitor.backgroundrunner.RunnerService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
<!-- END OF SERVICE DECLARATION -->
|
||||
|
||||
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<!-- Basic permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Location permissions for background tracking -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
|
||||
<!-- Background service permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
|
||||
<!-- Camera permission for QR scanning -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Device information permissions -->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||
|
||||
<!-- App detection and security permissions -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.GET_PACKAGE_SIZE" />
|
||||
|
||||
<!-- Battery optimization permissions -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- Boot receiver for auto-start -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Notification permissions for Android 13+ -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Mock location detection -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MOCK_LOCATION"
|
||||
tools:ignore="MockLocation,ProtectedPermissions" />
|
||||
|
||||
<!-- Features -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.location"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.location.gps"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.location.network"
|
||||
android:required="false" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.ouji.factory.myapp;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">nilai-clock</string>
|
||||
<string name="title_activity_main">nilai-clock</string>
|
||||
<string name="package_name">com.ouji.factory.myapp</string>
|
||||
<string name="custom_url_scheme">com.ouji.factory.myapp</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Allow cleartext traffic for development -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<domain includeSubdomains="true">198.18.0.1</domain>
|
||||
<domain includeSubdomains="true">192.168.36.54</domain>
|
||||
<!-- Add your specific IP range here -->
|
||||
<trust-anchors>
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</domain-config>
|
||||
<!-- Debug overrides for development -->
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<!-- Trust user-installed certificates (for development) -->
|
||||
<certificates src="user"/>
|
||||
<!-- Trust system certificates -->
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
|
||||
<!-- Base configuration for all other domains -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.10.1'
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-background-geolocation'
|
||||
project(':capacitor-community-background-geolocation').projectDir = new File('../node_modules/@capacitor-community/background-geolocation/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-app-launcher'
|
||||
project(':capacitor-app-launcher').projectDir = new File('../node_modules/@capacitor/app-launcher/android')
|
||||
|
||||
include ':capacitor-device'
|
||||
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
|
||||
|
||||
include ':capacitor-geolocation'
|
||||
project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android')
|
||||
|
||||
include ':capacitor-network'
|
||||
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
@@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
@@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEajCCAtKgAwIBAgIQCidY0lKaDwojBgr6MpeBzzANBgkqhkiG9w0BAQsFADCB
|
||||
kTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlM
|
||||
XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMM
|
||||
MW1rY2VydCBNQUlMXG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lh
|
||||
bikwHhcNMjUwNzA0MDc0NjExWhcNMjcxMDA0MDc0NjExWjBeMScwJQYDVQQKEx5t
|
||||
a2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxMzAxBgNVBAsMKk1BSUxcbWFz
|
||||
b25neWFuQERFU0tUT1AtSVFVOERERCAobWFzb25neWFuKTCCASIwDQYJKoZIhvcN
|
||||
AQEBBQADggEPADCCAQoCggEBANl8SofEGCDGYv2J22Qanu6LgxvvKd9wKB1Lf2x6
|
||||
eBD84tHmVZXKuQElo9ZkEbljKA9M8dNCTrxNFzGL6dB2b3fRHBnEYhiANKnMohgb
|
||||
oul+Tiq2/Pye4SHWglvsM6DboImARRW58L8FyA3mnS9VgS7TUb3W2tRQhLHU1s/R
|
||||
QjZulIQvpe+k0dW+S1zd7wBg790K5GNs9va/8KEM1v3esBNOpCbKeWzeRT/Si9ZA
|
||||
Dfm72SSWslHQEXtuz8AQVtfk0qJMUB0URmyadir0aJwuDC6m5iQSKtLTvQp+n0/Z
|
||||
lundQQbsnm71FnCAD9PSz+IaB3euEOwUGbGnDW9+10kGTekCAwEAAaNwMG4wDgYD
|
||||
VR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFElR
|
||||
m5C15845O14vXvSvwjxwtiJEMCYGA1UdEQQfMB2CCWxvY2FsaG9zdIcECgACAocE
|
||||
fwAAAYcEwKgkNjANBgkqhkiG9w0BAQsFAAOCAYEAsOdvadeTxsAT0Le63PPEYPiZ
|
||||
drkEJdTyu9Thv9nFhLCD4vUYIZrlE3brFXD1iVTR1muJsalfnmW9azIwGBHw52bZ
|
||||
B2XdA6HNZEklSRtqNMEAGJsdnbGuCTPa1lLNuzCQodSnmbvu6Y5K13Pq/asl3DVW
|
||||
h/hczwX5NrQvlvyDwI0kVSDRmEb5AYnEic5h64gEyILTVWopT8RzA+B8AtW3oP3d
|
||||
pfoCErwQvxfkNd3UGWk+rDlQWwApzh+N4P+3vAjhAra7Yoj+JtT0SnXeAjXhbB0E
|
||||
WmDcMNQwxUg1FN5ATR5pAMoSSNviLaf/jYb93naZ6YZKgSfSIKNgUJz+ppgHNBFr
|
||||
326JOYH0yzyhWXUXchzsn1ytMkhddNVZhRbGceOkyZEkaSynZR4om8ZGxPJYfCBB
|
||||
m9sH27eCeJBy9DXk0ZUkJg+y3C+jizenHiPnED92Z1EZ0ke7fNufiVZs0yQl2uxg
|
||||
V5mgoQSLxu4LHXQnTm/NQugY9S8rfbz510WutGKi
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDZfEqHxBggxmL9
|
||||
idtkGp7ui4Mb7ynfcCgdS39sengQ/OLR5lWVyrkBJaPWZBG5YygPTPHTQk68TRcx
|
||||
i+nQdm930RwZxGIYgDSpzKIYG6Lpfk4qtvz8nuEh1oJb7DOg26CJgEUVufC/BcgN
|
||||
5p0vVYEu01G91trUUISx1NbP0UI2bpSEL6XvpNHVvktc3e8AYO/dCuRjbPb2v/Ch
|
||||
DNb93rATTqQmynls3kU/0ovWQA35u9kklrJR0BF7bs/AEFbX5NKiTFAdFEZsmnYq
|
||||
9GicLgwupuYkEirS070Kfp9P2Zbp3UEG7J5u9RZwgA/T0s/iGgd3rhDsFBmxpw1v
|
||||
ftdJBk3pAgMBAAECggEAIeDztzx7ybc9umMcMvbWpTBEZziVXEIbbZzSJ7LYO0U5
|
||||
jBsGYAQpV51mbUI/ZJKmreN9lDwzCbA0mbpC3P9mE9MWPolSAqEOExlWcszzTs4n
|
||||
HQ5OUIfraBsDSZB85mTwGBtMJ7tEXm1nIYs4FySJsCKpDBqJEiPM1+rg35SobNP5
|
||||
aOvuLgXe3V6wVuihakoGj8nUtCgKsPr/14ybcF6Fcv5ULI6Tls0G8HOY92Kesb/o
|
||||
NZL1YmMVevY+RKYzrZKca6mRanMIjnjrnYGX5V404mh6GQKpGdgrcMEONMbJje2H
|
||||
44MjyJYhQ67/ItOKOuC1JG1LuRq/5SXTAS2WW7g+1QKBgQDiWefUn2v3pYd4CIFd
|
||||
Bz43TpHuQZiqX5UOvPFOrk5LT+EhYHTpSCThrc5piqk+XsnV3G1dyDnbBK8k4FPa
|
||||
yyrUuNOSvQlspSr0u++5i7cRLwq7C6kRtTzW8nr6Az8bE6u1prvXKFIWKP/doWeg
|
||||
U7jPMCVKN+oxvNN6Fi0meecLxwKBgQD1+RkfrUCg7xpr+gn2R2LxryL2u/oxVRmo
|
||||
4TZqBQoXcQJBx+UrTcIL8XENohYYI/7HCZfD/cBxpFGNqclD3DjzjH2NZ43MBlbN
|
||||
up3wD+Ks2LVOilyOrxK3be/cnvPyQJantd/NBnHOTsQoBUPdhbrqdyrjYW0o4WZQ
|
||||
5c36f934zwKBgQCRiTEQeviWoG279ewHfpK4SOJ3iOG6Gf7jHQUii9x3fALKzRQe
|
||||
sm5UVMZ1AdzT52prAXGobQcWFarvUPVZpmwBnl0a6kTXAFPgS75VVMn+WHrTzSmF
|
||||
4zwdEIeVnOTEah9riqsYKiqtaOsq+45/fZVEUjaHw+/mzvxCcWPSa2rtHQKBgEUe
|
||||
amDsXmzaw6Hz8TizdqpTfI+44uVZ9IvwPUotgFh1+Rxi/5LbltukTRB3q528/6sO
|
||||
lwcMFzfX5NLaEyRujdJieCV0I/RhE6Nb/WWoERphCxG276topunEitKEGCjK3Yrj
|
||||
ILCMTw6aM6TLVfa5zXx1YCflCLekHww8h1UM+WMhAoGAH6U1XzkW3ozty7sQ5vxZ
|
||||
jzri0xUpp06EA/EtfhkCRPgaYCkL5aXan+jNAZPfTG6mGudULWjTIfEEQrMJ54CN
|
||||
sItMoPP2S4EDuj4xdQWe8eTeMqtGG/lAmG2Yr9QajWofNLwaBtsXANYCDGadNUxa
|
||||
2pog6+BDaFEC64IwkoBYgZ8=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"appId": "com.ouji.factory.myapp",
|
||||
"appName": "nilai-clock",
|
||||
"webDir": "dist",
|
||||
"plugins": {
|
||||
"Geolocation": {
|
||||
"enableHighAccuracy": true,
|
||||
"timeout": 10000,
|
||||
"maximumAge": 3600000
|
||||
},
|
||||
"Preferences": {
|
||||
"group": "NilaiClockApp"
|
||||
},
|
||||
"Device": {
|
||||
"allowedFields": [
|
||||
"platform",
|
||||
"model",
|
||||
"manufacturer",
|
||||
"osVersion",
|
||||
"appVersion",
|
||||
"memUsed",
|
||||
"diskFree",
|
||||
"diskTotal",
|
||||
"isVirtual",
|
||||
"webViewVersion"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
version: '3'
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- '18081:3000'
|
||||
environment:
|
||||
- DB_HOST=
|
||||
- DB_USER=nilai_clock
|
||||
- DB_PASSWORD=
|
||||
- DB_NAME=nilai_clock
|
||||
- DB_PORT=3306
|
||||
- VITE_API_BASE_URL=//seiyaku.whealthfields.com.my/api
|
||||
volumes:
|
||||
- ./rebuild/dist:/app/dist
|
||||
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "18080:80"
|
||||
volumes:
|
||||
- ./build/dist:/usr/share/nginx/html
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**', 'android/app/build/**', 'android/app/src/main/assets/**', 'node_modules/**']),
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
skipFormatting,
|
||||
])
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "nilai-clock",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:https": "vite --host --https",
|
||||
"backend": "node ./backend/server.js",
|
||||
"backend:https": "node start-backend-https.js",
|
||||
"dev:all": "concurrently \"npm run dev\" \"npm run backend\"",
|
||||
"dev:all:https": "concurrently \"npm run dev:https\" \"npm run backend:https\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write src/",
|
||||
"generate-certs": "node generate-ssl-mkcert.js",
|
||||
"generate-certs-openssl": "node generate-ssl-certs.js",
|
||||
"generate-certs-simple": "node generate-certs-simple.js",
|
||||
"setup-https": "node setup-https.js",
|
||||
"build:local-http": "copy .env.local-http .env.production && npm run build && npx cap copy android",
|
||||
"build:local-https": "copy .env.local-https .env.production && npm run build && npx cap copy android",
|
||||
"build:production": "copy .env.production-server .env.production && npm run build && npx cap copy android",
|
||||
"switch:local-http": "copy .env.local-http .env.production",
|
||||
"switch:local-https": "copy .env.local-https .env.production",
|
||||
"switch:production": "copy .env.production-server .env.production",
|
||||
"switch": "node switch-server.js",
|
||||
"build:android": "npm run build && npx cap copy android && npx cap sync android",
|
||||
"open:android": "npx cap open android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/background-geolocation": "^1.2.22",
|
||||
"@capacitor/android": "^7.4.0",
|
||||
"@capacitor/app": "^7.0.1",
|
||||
"@capacitor/app-launcher": "^7.0.1",
|
||||
"@capacitor/core": "^7.4.0",
|
||||
"@capacitor/device": "^7.0.1",
|
||||
"@capacitor/geolocation": "^7.1.2",
|
||||
"@capacitor/network": "^7.0.1",
|
||||
"@capacitor/preferences": "^7.0.1",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@primeuix/themes": "^1.1.2",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"json2csv": "^6.0.0-alpha.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.14.1",
|
||||
"primevue": "^4.3.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.7",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.0",
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^9.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-vue": "~10.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"postcss": "^8.4.31",
|
||||
"prettier": "3.5.3",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 text-gray-900">
|
||||
<!-- Mobile-native layout without top bar -->
|
||||
<main class="pb-20">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<!-- Bottom Navigation (only show for worker routes) -->
|
||||
<BottomNavigation v-if="showBottomNav" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onBeforeUnmount, computed } from 'vue'
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { App } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
import { authService } from '@/services/authService.js'
|
||||
import BottomNavigation from '@/components/BottomNavigation.vue'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isLoggedIn = ref(!!sessionStorage.getItem('userId'))
|
||||
|
||||
// Show bottom navigation only for worker routes
|
||||
const showBottomNav = computed(() => {
|
||||
return route.path.startsWith('/worker/')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
isLoggedIn.value = !!sessionStorage.getItem('userId')
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Restore language
|
||||
const savedLang = localStorage.getItem('lang')
|
||||
if (savedLang) {
|
||||
locale.value = savedLang
|
||||
}
|
||||
|
||||
// Initialize native services
|
||||
try {
|
||||
console.log('Initializing native services...')
|
||||
await nativeServicesManager.initialize()
|
||||
|
||||
// Set up app state change listeners for native platforms
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) {
|
||||
nativeServicesManager.onAppForeground()
|
||||
} else {
|
||||
nativeServicesManager.onAppBackground()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Check for auto-login
|
||||
const autoLoginResult = await authService.attemptAutoLogin()
|
||||
if (autoLoginResult.success) {
|
||||
console.log('Auto-login successful')
|
||||
isLoggedIn.value = true
|
||||
|
||||
// Start native services
|
||||
await nativeServicesManager.onUserLogin()
|
||||
|
||||
// Navigate to appropriate dashboard
|
||||
if (autoLoginResult.userRole === 'worker') {
|
||||
router.push('/worker/dashboard')
|
||||
} else if (autoLoginResult.userRole === 'manager') {
|
||||
router.push('/manager/dashboard')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Clean up listeners
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
App.removeAllListeners()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* All styles are now handled by Tailwind CSS classes in the template. */
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { authService } from './services/authService.js'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
export async function apiFetch(endpoint, options = {}) {
|
||||
// Try to get token from native auth service first, fallback to sessionStorage
|
||||
let token = await authService.getAuthToken()
|
||||
if (!token) {
|
||||
token = sessionStorage.getItem('token')
|
||||
}
|
||||
|
||||
const defaultHeaders = {
|
||||
'ngrok-skip-browser-warning': 'true',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: defaultHeaders,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to parse the error response body from the server
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || `API call failed with status: ${response.status}`)
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-50">
|
||||
<div class="flex">
|
||||
<!-- Clock In Section -->
|
||||
<router-link
|
||||
to="/worker/dashboard"
|
||||
class="flex-1 flex flex-col items-center py-3 px-2 text-center transition-colors duration-200"
|
||||
:class="isClockInActive ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'"
|
||||
>
|
||||
<component :is="isClockInActive ? ClockIconSolid : ClockIconOutline" class="w-7 h-7 mb-1" />
|
||||
<span class="text-xs font-medium">{{ $t('clockIn') }}</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Personal Section -->
|
||||
<router-link
|
||||
to="/worker/personal"
|
||||
class="flex-1 flex flex-col items-center py-3 px-2 text-center transition-colors duration-200"
|
||||
:class="isPersonalActive ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'"
|
||||
>
|
||||
<component :is="isPersonalActive ? UserIconSolid : UserIconOutline" class="w-7 h-7 mb-1" />
|
||||
<span class="text-xs font-medium">{{ $t('personal') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ClockIcon as ClockIconOutline, UserIcon as UserIconOutline } from '@heroicons/vue/24/outline'
|
||||
import { ClockIcon as ClockIconSolid, UserIcon as UserIconSolid } from '@heroicons/vue/24/solid'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Computed properties for active states
|
||||
const isClockInActive = computed(() => route.path === '/worker/dashboard')
|
||||
const isPersonalActive = computed(() =>
|
||||
route.path.includes('/worker/personal') ||
|
||||
route.path.includes('/worker/history') ||
|
||||
route.path.includes('/worker/change-password')
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional mobile-specific styles */
|
||||
@media (max-width: 640px) {
|
||||
nav {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">
|
||||
{{ $t('servicesStatus') }}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Overall Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('overallStatus') }}</span>
|
||||
<span :class="overallStatusClass">
|
||||
{{ overallStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Location Tracking -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('locationTracking') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="locationStatusClass">
|
||||
{{ locationStatusText }}
|
||||
</span>
|
||||
<button
|
||||
v-if="isNative && !serviceStatus.services?.location?.tracking"
|
||||
@click="startLocationTracking"
|
||||
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600"
|
||||
>
|
||||
{{ $t('start') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Registration -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('deviceRegistration') }}</span>
|
||||
<span :class="deviceStatusClass">
|
||||
{{ deviceStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Security Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('securityStatus') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="securityStatusClass">
|
||||
{{ securityStatusText }}
|
||||
</span>
|
||||
<button
|
||||
@click="runSecurityCheck"
|
||||
:disabled="securityCheckRunning"
|
||||
class="text-xs bg-green-500 text-white px-2 py-1 rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{{ securityCheckRunning ? $t('checking') : $t('check') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Location Update -->
|
||||
<div v-if="serviceStatus.services?.location?.lastUpdate" class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('lastLocationUpdate') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatLastUpdate(serviceStatus.services.location.lastUpdate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Device UUID -->
|
||||
<div v-if="serviceStatus.services?.deviceUuid?.uuid" class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ $t('deviceId') }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{{ serviceStatus.services.deviceUuid.uuid.substring(0, 8) }}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Button -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
@click="refreshStatus"
|
||||
:disabled="refreshing"
|
||||
class="text-sm bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{{ refreshing ? $t('refreshing') : $t('refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div v-if="errorMessage" class="mt-3 p-2 bg-red-100 text-red-700 rounded text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Success Messages -->
|
||||
<div v-if="successMessage" class="mt-3 p-2 bg-green-100 text-green-700 rounded text-sm">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const serviceStatus = ref({})
|
||||
const refreshing = ref(false)
|
||||
const securityCheckRunning = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const isNative = Capacitor.isNativePlatform()
|
||||
|
||||
// Computed properties for status display
|
||||
const overallStatusClass = computed(() => {
|
||||
if (!serviceStatus.value.isInitialized) return 'text-red-500 text-sm'
|
||||
return 'text-green-500 text-sm'
|
||||
})
|
||||
|
||||
const overallStatusText = computed(() => {
|
||||
if (!serviceStatus.value.isInitialized) return t('notInitialized')
|
||||
return t('ready')
|
||||
})
|
||||
|
||||
const locationStatusClass = computed(() => {
|
||||
if (!isNative) return 'text-gray-500 text-sm'
|
||||
if (serviceStatus.value.services?.location?.tracking) return 'text-green-500 text-sm'
|
||||
return 'text-red-500 text-sm'
|
||||
})
|
||||
|
||||
const locationStatusText = computed(() => {
|
||||
if (!isNative) return t('webOnly')
|
||||
if (serviceStatus.value.services?.location?.tracking) return t('active')
|
||||
return t('inactive')
|
||||
})
|
||||
|
||||
const deviceStatusClass = computed(() => {
|
||||
if (serviceStatus.value.services?.deviceUuid?.uuid) return 'text-green-500 text-sm'
|
||||
return 'text-yellow-500 text-sm'
|
||||
})
|
||||
|
||||
const deviceStatusText = computed(() => {
|
||||
if (serviceStatus.value.services?.deviceUuid?.uuid) return t('registered')
|
||||
return t('pending')
|
||||
})
|
||||
|
||||
const securityStatusClass = computed(() => {
|
||||
const lastCheck = serviceStatus.value.services?.antiSpoofing?.lastCheck
|
||||
if (!lastCheck) return 'text-yellow-500 text-sm'
|
||||
|
||||
const hoursSinceCheck = (Date.now() - new Date(lastCheck).getTime()) / (1000 * 60 * 60)
|
||||
if (hoursSinceCheck > 24) return 'text-yellow-500 text-sm'
|
||||
return 'text-green-500 text-sm'
|
||||
})
|
||||
|
||||
const securityStatusText = computed(() => {
|
||||
const lastCheck = serviceStatus.value.services?.antiSpoofing?.lastCheck
|
||||
if (!lastCheck) return t('notChecked')
|
||||
|
||||
const hoursSinceCheck = (Date.now() - new Date(lastCheck).getTime()) / (1000 * 60 * 60)
|
||||
if (hoursSinceCheck > 24) return t('outdated')
|
||||
return t('current')
|
||||
})
|
||||
|
||||
// Methods
|
||||
const refreshStatus = async () => {
|
||||
refreshing.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
serviceStatus.value = nativeServicesManager.getServiceStatus()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh status:', error)
|
||||
errorMessage.value = t('failedToRefreshStatus')
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startLocationTracking = async () => {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
await nativeServicesManager.startServices()
|
||||
successMessage.value = t('locationTrackingStarted')
|
||||
await refreshStatus()
|
||||
} catch (error) {
|
||||
console.error('Failed to start location tracking:', error)
|
||||
errorMessage.value = t('failedToStartLocationTracking')
|
||||
}
|
||||
}
|
||||
|
||||
const runSecurityCheck = async () => {
|
||||
securityCheckRunning.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
await nativeServicesManager.forceSecurityCheck()
|
||||
successMessage.value = t('securityCheckComplete')
|
||||
await refreshStatus()
|
||||
} catch (error) {
|
||||
console.error('Security check failed:', error)
|
||||
errorMessage.value = t('securityCheckFailed')
|
||||
} finally {
|
||||
securityCheckRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatLastUpdate = (timestamp) => {
|
||||
if (!timestamp) return t('never')
|
||||
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMinutes = Math.floor((now - date) / (1000 * 60))
|
||||
|
||||
if (diffMinutes < 1) return t('justNow')
|
||||
if (diffMinutes < 60) return t('minutesAgo', { minutes: diffMinutes })
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
if (diffHours < 24) return t('hoursAgo', { hours: diffHours })
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return t('daysAgo', { days: diffDays })
|
||||
}
|
||||
|
||||
const clearMessages = () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await refreshStatus()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(refreshStatus, 30000)
|
||||
|
||||
// Clear messages after 5 seconds
|
||||
setInterval(clearMessages, 5000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Component-specific styles if needed */
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
console.log("[DEBUG] i18n.js loaded!"); // very top
|
||||
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from './locales/en.json';
|
||||
import ms from './locales/ms.json';
|
||||
|
||||
console.log("[DEBUG] en.json:", en);
|
||||
console.log("[DEBUG] ms.json:", ms);
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en', // Default to English
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, ms }
|
||||
});
|
||||
|
||||
console.log("[DEBUG] i18n instance created:", i18n);
|
||||
export default i18n;
|
||||
|
||||
console.log("[DEBUG] i18n.js export complete");
|
||||
@@ -0,0 +1,249 @@
|
||||
{
|
||||
"appTitle": "Clock-In/Out System",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"loggingIn": "Logging in...",
|
||||
"language": "Language",
|
||||
"failedConnection": "Failed to connect to the server.",
|
||||
"invalidToken": "Invalid token received from server.",
|
||||
"invalidCredentials": "Invalid username or password.",
|
||||
"english": "English",
|
||||
"malay": "Malay",
|
||||
|
||||
|
||||
|
||||
"yourStatus": "Your Status",
|
||||
"clockedIn": "Clocked In",
|
||||
"clockedOut": "Clocked Out",
|
||||
"clockIn": "Clock In",
|
||||
"clockOut": "Clock Out",
|
||||
"clock_in": "Clock In",
|
||||
"clock_out": "Clock Out",
|
||||
|
||||
"scanToClock": "Scan to Clock {action}",
|
||||
"in": "In",
|
||||
"out": "Out",
|
||||
"cancel": "Cancel",
|
||||
|
||||
"viewMyClockHistory": "View My Clock History",
|
||||
"changeMyPassword": "Change My Password",
|
||||
"myClockHistory": "My Clock History",
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"noClockHistory": "You have no clocking history.",
|
||||
"clockHistoryFetchFail": "Failed to fetch clock history:",
|
||||
"viewClockHistory": "View My Clock History →",
|
||||
"changePassword": "Change My Password →",
|
||||
"invalidCurrentPassword": "Invalid current password.",
|
||||
|
||||
"successClockIn": "Successfully clocked in.",
|
||||
"successClockOut": "Successfully clocked out.",
|
||||
"qrFail": "Could not detect a QR code. Please try again.",
|
||||
"geoFail": "Unable to retrieve your location: {message}. Please enable location services.",
|
||||
"successClock": "Successfully clocked at {location}.",
|
||||
"changePasswordTitle": "Change Password",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmNewPassword": "Confirm New Password",
|
||||
"updating": "Updating...",
|
||||
|
||||
"tabPersonnel": "Personnel",
|
||||
"tabAttendance": "Attendance",
|
||||
"tabQrCodes": "QR Codes",
|
||||
"uploadQrImage": "Upload QR Image",
|
||||
|
||||
"couldNotLoadWorkerInfo": "Could not load worker information",
|
||||
"couldNotVerifyStatus": "Could not verify current status from server",
|
||||
"successfullyClocked": "Successfully clocked {action} at",
|
||||
"site": "site",
|
||||
"errorOccurred": "Error occurred",
|
||||
"unableToStartCamera": "Unable to start camera.",
|
||||
"tryAgain": "Try Again",
|
||||
"qrDetectedGettingLocation": "QR Code detected. Getting location...",
|
||||
"geolocationNotSupported": "Geolocation is not supported by your browser.",
|
||||
"unableToRetrieveLocation": "Unable to retrieve your location: {message}. Please enable location services.",
|
||||
"qrNotDetectedTryAgain": "Could not detect a QR code. Please try again.",
|
||||
"updatePassword": "Update Password",
|
||||
"passwordsNoMatch": "New passwords do not match.",
|
||||
"passwordTooShort": "New password must be at least 6 characters long.",
|
||||
"passwordUpdated": "Password updated successfully! You can now use your new password to log in.",
|
||||
"passwordUpdateError": "An error occurred while updating the password.",
|
||||
|
||||
"attendanceLogFor": "Attendance Log for",
|
||||
"addManualClockOut": "Add Manual Clock-Out",
|
||||
"manualClockOutInstruction": "Use this form if the worker forgot to clock out. The last event must be a clock-in.",
|
||||
"clockOutTime": "Clock-Out Time",
|
||||
"reason": "Reason (e.g., \"Forgot to clock out\")",
|
||||
"enterBriefNote": "Enter a brief note",
|
||||
"addRecord": "Add Record",
|
||||
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"filterRecords": "Filter Records",
|
||||
"event": "Event",
|
||||
"timestamp": "Timestamp",
|
||||
"locationName": "Location Name",
|
||||
"coordinates": "Coordinates",
|
||||
"notes": "Notes",
|
||||
"noRecordsFound": "No records found for this period.",
|
||||
"showOnMap": "Show on map",
|
||||
"nA": "N/A",
|
||||
"pleaseSelectTimestamp": "Please select a timestamp for the clock-out.",
|
||||
"pleaseProvideReason": "Please provide a reason/note for the manual entry.",
|
||||
"manualClockOutSuccess": "Manual clock-out recorded successfully!",
|
||||
"manualClockOutError": "An error occurred: {message}",
|
||||
|
||||
"selectWorkers": "1. Select Workers",
|
||||
"searchWorkerPlaceholder": "Search for a worker...",
|
||||
"selectAll": "Select All",
|
||||
"addWorkersByTag": "Add all workers from a tag",
|
||||
"chooseTag": "-- Choose a tag --",
|
||||
"addByTag": "Add by Tag",
|
||||
"selectedForReport": "Selected for Report ({count})",
|
||||
"allWorkersSelected": "All Workers ({count}) Selected",
|
||||
"noWorkersSelected": "No workers selected.",
|
||||
"reportSettings": "2. Report Settings",
|
||||
"monthlySalary": "Monthly Salary (RM)",
|
||||
"salaryAppliedNote": "Applied to all selected workers.",
|
||||
"salaryPlaceholder": "e.g., 3000",
|
||||
"otFactors": "OT Factors",
|
||||
"weekendFactor": "Weekend Factor",
|
||||
"holidayFactor": "Holiday Factor",
|
||||
"selectPublicHolidays": "Select Public Holidays",
|
||||
"generateReport": "Generate Attendance & OT Report",
|
||||
"overtimePaySummary": "Overtime Pay Summary",
|
||||
"exportOtSummary": "Export OT Summary (CSV)",
|
||||
"worker": "Worker",
|
||||
"totalHoursWorked": "Total Hours Worked",
|
||||
"totalOtPay": "Total OT Pay (RM)",
|
||||
"rawAttendanceData": "Raw Attendance Data",
|
||||
"loadingReport": "Loading Report...",
|
||||
"tagLoadError": "Could not load workers for the selected tag.",
|
||||
"generateReportError": "Please select workers, set valid date range, and enter a salary.",
|
||||
"reportGenerationError": "An error occurred while generating the report.",
|
||||
|
||||
"addNewUser": "Add New User",
|
||||
"fullName": "Full Name",
|
||||
"egJohnSmith": "e.g. John Smith",
|
||||
"egJsmith": "e.g. jsmith",
|
||||
"eg123456": "e.g. 123456",
|
||||
"asManager": "As Manager",
|
||||
"adding": "Adding...",
|
||||
"addUser": "Add User",
|
||||
"manageTags": "Manage Tags",
|
||||
"createNewTag": "Create New Tag",
|
||||
"egTeam": "e.g. Team",
|
||||
"createTag": "Create Tag",
|
||||
"tags": "Tags",
|
||||
"workerRoster": "Worker Roster",
|
||||
"searchByNameOrUsername": "Search by name or username",
|
||||
"filterByTag": "Filter by tag",
|
||||
"clearFilter": "Clear filter",
|
||||
"dateJoined": "Date Joined",
|
||||
"actions": "Actions",
|
||||
"editTags": "Edit Tags",
|
||||
"viewRecords": "View Records",
|
||||
"delete": "Delete",
|
||||
"loadingWorkers": "Loading workers...",
|
||||
"noWorkersFound": "No workers found.",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageOf": "Page {current} of {total}",
|
||||
"noTagsAvailable": "No tags available.",
|
||||
"done": "Done",
|
||||
"bulkEditTags": "Bulk Edit Tags",
|
||||
"clearSelection": "Clear Selection",
|
||||
"forUser": "For user",
|
||||
"savePassword": "Save Password",
|
||||
"saving": "Saving...",
|
||||
"failedToUpdateTags": "Failed to update tags. Please try again.",
|
||||
"tagDeleted": "Tag deleted successfully.",
|
||||
"failedToFetchWorkers": "Failed to fetch workers.",
|
||||
"failedToLoadPageData": "Failed to load page data.",
|
||||
"errorAddingUser": "An error occurred while adding the user.",
|
||||
"failedToDeleteWorker": "Failed to delete worker.",
|
||||
"areYouSureDeleteWorker": "Are you sure you want to delete this worker account?",
|
||||
"areYouSureDeleteTag": "Are you sure you want to delete this tag? This will remove it from all workers.",
|
||||
"failedToDeleteTag": "Failed to delete tag.",
|
||||
"passwordsDoNotMatch": "Passwords do not match.",
|
||||
"createQrCode": "Create New QR Code",
|
||||
"qrCodeName": "QR Code Name",
|
||||
"qrNamePlaceholder": "e.g., 'West Gate Entrance'",
|
||||
"create": "Create",
|
||||
"newCodeCreated": "New Code Created!",
|
||||
"saveQrInstruction": "Save this image or use the ID below. This will disappear on refresh.",
|
||||
"id": "ID",
|
||||
"existingQrCodes": "Existing QR Codes",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"deactivate": "Deactivate",
|
||||
"activate": "Activate",
|
||||
"download": "Download",
|
||||
"noQrCodesFound": "No QR codes found. Create one above!",
|
||||
"deleteQrConfirm": "Are you sure you want to delete this QR code? This cannot be undone.",
|
||||
"qrDownloadError": "Sorry, the QR code could not be downloaded.",
|
||||
|
||||
"rememberMe": "Remember me for auto-login",
|
||||
"deviceNotAuthorized": "This device is not authorized for your account. Please contact your administrator.",
|
||||
"locationTrackingActive": "Location tracking is active in the background",
|
||||
"securityCheckInProgress": "Performing security check...",
|
||||
"securityCheckComplete": "Security check completed successfully",
|
||||
"highSecurityRisk": "High security risk detected. Please contact your administrator.",
|
||||
"deviceRegistered": "Device registered successfully",
|
||||
"autoLoginEnabled": "Auto-login enabled for this device",
|
||||
"backgroundLocationEnabled": "Background location tracking enabled",
|
||||
"permissionsRequired": "Location permissions are required for attendance tracking",
|
||||
"batteryOptimizationWarning": "Please disable battery optimization for this app to ensure continuous location tracking",
|
||||
"gpsSpooferDetected": "GPS spoofing application detected. This may affect attendance accuracy.",
|
||||
"mockLocationEnabled": "Mock location is enabled. Please disable it for accurate attendance tracking.",
|
||||
"deviceSecurityWarning": "Device security warning: Suspicious applications detected",
|
||||
"locationUpdateFailed": "Failed to update location. Will retry automatically.",
|
||||
"servicesInitializing": "Initializing native services...",
|
||||
"servicesReady": "All services are ready",
|
||||
"autoLoginFailed": "Auto-login failed. Please log in manually.",
|
||||
"deviceValidationFailed": "Device validation failed. Please contact support.",
|
||||
|
||||
"servicesStatus": "Services Status",
|
||||
"overallStatus": "Overall Status",
|
||||
"locationTracking": "Location Tracking",
|
||||
"deviceRegistration": "Device Registration",
|
||||
"securityStatus": "Security Status",
|
||||
"lastLocationUpdate": "Last Location Update",
|
||||
"deviceId": "Device ID",
|
||||
"start": "Start",
|
||||
"check": "Check",
|
||||
"checking": "Checking...",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"notInitialized": "Not Initialized",
|
||||
"ready": "Ready",
|
||||
"webOnly": "Web Only",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"registered": "Registered",
|
||||
"pending": "Pending",
|
||||
"notChecked": "Not Checked",
|
||||
"outdated": "Outdated",
|
||||
"current": "Current",
|
||||
"never": "Never",
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{minutes}m ago",
|
||||
"hoursAgo": "{hours}h ago",
|
||||
"daysAgo": "{days}d ago",
|
||||
"failedToRefreshStatus": "Failed to refresh status",
|
||||
"locationTrackingStarted": "Location tracking started successfully",
|
||||
"failedToStartLocationTracking": "Failed to start location tracking",
|
||||
"securityCheckFailed": "Security check failed",
|
||||
|
||||
"personal": "Personal",
|
||||
"clockHistory": "Clock History",
|
||||
"openCamera": "Open Camera",
|
||||
"scanQRCode": "Scan QR Code",
|
||||
"services": "Services",
|
||||
"systemServicesStatus": "System services and security status",
|
||||
"updateYourPassword": "Update your account password",
|
||||
"signOutOfAccount": "Sign out of your account"
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
{
|
||||
"appTitle": "Sistem Masuk/Keluar Kerja",
|
||||
"logout": "Log Keluar",
|
||||
"login": "Log Masuk",
|
||||
"username": "Nama Pengguna",
|
||||
"password": "Kata Laluan",
|
||||
"loggingIn": "Sedang log masuk...",
|
||||
"language": "Bahasa",
|
||||
"failedConnection": "Gagal untuk berhubung dengan pelayan.",
|
||||
"invalidCredentials": "Nama pengguna atau kata laluan tidak sah.",
|
||||
"invalidToken": "Token tidak sah diterima dari pelayan.",
|
||||
"english": "Bahasa Inggeris",
|
||||
"malay": "Bahasa Melayu",
|
||||
|
||||
"yourStatus": "Status Anda",
|
||||
"clockedIn": "Sudah Masuk",
|
||||
"clockedOut": "Sudah Keluar",
|
||||
"clockIn": "Masuk Kerja",
|
||||
"clockOut": "Keluar Kerja",
|
||||
"clock_in": "Masuk Kerja",
|
||||
"clock_out": "Keluar Kerja",
|
||||
|
||||
"scanToClock": "Imbas untuk {action} Kerja",
|
||||
"in": "Masuk",
|
||||
"out": "Keluar",
|
||||
"cancel": "Batal",
|
||||
|
||||
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
|
||||
"changeMyPassword": "Tukar Kata Laluan Saya",
|
||||
"myClockHistory": "Sejarah Kehadiran Saya",
|
||||
"backToDashboard": "Kembali ke Papan Pemuka",
|
||||
"noClockHistory": "Tiada rekod kehadiran.",
|
||||
"clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:",
|
||||
"viewClockHistory": "Lihat Sejarah Kehadiran Saya →",
|
||||
"changePassword": "Tukar Kata Laluan Saya →",
|
||||
"invalidCurrentPassword": "Kata laluan semasa tidak sah.",
|
||||
"successClockIn": "Berjaya masuk kerja.",
|
||||
"successClockOut": "Berjaya keluar kerja.",
|
||||
"qrFail": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
|
||||
"geoFail": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
|
||||
"successClock": "Berjaya daftar di {location}.",
|
||||
"changePasswordTitle": "Tukar Kata Laluan",
|
||||
"currentPassword": "Kata Laluan Semasa",
|
||||
"newPassword": "Kata Laluan Baharu",
|
||||
"confirmNewPassword": "Sahkan Kata Laluan Baharu",
|
||||
"updating": "Mengemaskini...",
|
||||
|
||||
"tabPersonnel": "Personel",
|
||||
"tabAttendance": "Kehadiran",
|
||||
"tabQrCodes": "Kod QR",
|
||||
"uploadQrImage": "Muat Naik Imej QR",
|
||||
|
||||
"couldNotLoadWorkerInfo": "Tidak dapat memuatkan maklumat pekerja",
|
||||
"couldNotVerifyStatus": "Tidak dapat mengesahkan status semasa dari pelayan",
|
||||
"successfullyClocked": "Berjaya {action} di",
|
||||
"site": "tapak",
|
||||
"errorOccurred": "Ralat telah berlaku",
|
||||
"unableToStartCamera": "Tidak dapat menghidupkan kamera.",
|
||||
"tryAgain": "Cuba Lagi",
|
||||
"qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...",
|
||||
"geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.",
|
||||
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
|
||||
"qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
|
||||
"updatePassword": "Kemaskini Kata Laluan",
|
||||
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
|
||||
"passwordTooShort": "Kata laluan baharu mesti sekurang-kurangnya 6 aksara.",
|
||||
"passwordUpdated": "Kata laluan berjaya dikemaskini! Anda boleh guna kata laluan baharu untuk log masuk.",
|
||||
"passwordUpdateError": "Ralat semasa mengemaskini kata laluan.",
|
||||
|
||||
"attendanceLogFor": "Log Kehadiran untuk",
|
||||
"addManualClockOut": "Tambah Clock-Out Manual",
|
||||
"manualClockOutInstruction": "Gunakan borang ini jika pekerja lupa untuk clock-out. Acara terakhir mesti clock-in.",
|
||||
"clockOutTime": "Masa Clock-Out",
|
||||
"reason": "Sebab (cth: \"Lupa clock-out\")",
|
||||
"enterBriefNote": "Masukkan nota ringkas",
|
||||
"addRecord": "Tambah Rekod",
|
||||
|
||||
"startDate": "Tarikh Mula",
|
||||
"endDate": "Tarikh Tamat",
|
||||
"filterRecords": "Tapis Rekod",
|
||||
"event": "Acara",
|
||||
"timestamp": "Cap Masa",
|
||||
"locationName": "Nama Lokasi",
|
||||
"coordinates": "Koordinat",
|
||||
"notes": "Nota",
|
||||
"noRecordsFound": "Tiada rekod untuk tempoh ini.",
|
||||
"showOnMap": "Papar di peta",
|
||||
"nA": "Tiada",
|
||||
"pleaseSelectTimestamp": "Sila pilih cap masa untuk clock-out.",
|
||||
"pleaseProvideReason": "Sila berikan sebab/nota untuk kemasukan manual.",
|
||||
"manualClockOutSuccess": "Clock-out manual berjaya direkod!",
|
||||
"manualClockOutError": "Ralat berlaku: {message}",
|
||||
|
||||
"selectWorkers": "1. Pilih Pekerja",
|
||||
"searchWorkerPlaceholder": "Cari pekerja...",
|
||||
"selectAll": "Pilih Semua",
|
||||
"addWorkersByTag": "Tambah semua pekerja berdasarkan tag",
|
||||
"chooseTag": "-- Pilih tag --",
|
||||
"addByTag": "Tambah melalui Tag",
|
||||
"selectedForReport": "Dipilih untuk Laporan ({count})",
|
||||
"allWorkersSelected": "Semua Pekerja ({count}) Dipilih",
|
||||
"noWorkersSelected": "Tiada pekerja dipilih.",
|
||||
"reportSettings": "2. Tetapan Laporan",
|
||||
"monthlySalary": "Gaji Bulanan (RM)",
|
||||
"salaryAppliedNote": "Diguna untuk semua pekerja yang dipilih.",
|
||||
"salaryPlaceholder": "cth: 3000",
|
||||
"otFactors": "Faktor OT",
|
||||
"weekendFactor": "Faktor Hujung Minggu",
|
||||
"holidayFactor": "Faktor Cuti Umum",
|
||||
"selectPublicHolidays": "Pilih Cuti Umum",
|
||||
"generateReport": "Jana Laporan Kehadiran & OT",
|
||||
"overtimePaySummary": "Ringkasan Bayaran OT",
|
||||
"exportOtSummary": "Eksport Ringkasan OT (CSV)",
|
||||
"worker": "Pekerja",
|
||||
"totalHoursWorked": "Jumlah Jam Bekerja",
|
||||
"totalOtPay": "Jumlah Bayaran OT (RM)",
|
||||
"rawAttendanceData": "Data Kehadiran Mentah",
|
||||
"loadingReport": "Memuatkan Laporan...",
|
||||
"tagLoadError": "Tidak dapat memuatkan pekerja untuk tag yang dipilih.",
|
||||
"generateReportError": "Sila pilih pekerja, tetapkan tarikh, dan masukkan gaji.",
|
||||
"reportGenerationError": "Ralat semasa menjana laporan.",
|
||||
"addNewUser": "Tambah Pengguna Baharu",
|
||||
"fullName": "Nama Penuh",
|
||||
"egJohnSmith": "cth. John Smith",
|
||||
"egJsmith": "cth. jsmith",
|
||||
"eg123456": "cth. 123456",
|
||||
"asManager": "Sebagai Pengurus",
|
||||
"adding": "Sedang menambah...",
|
||||
"addUser": "Tambah Pengguna",
|
||||
"manageTags": "Urus Tag",
|
||||
"createNewTag": "Cipta Tag Baharu",
|
||||
"egTeam": "cth. Pasukan",
|
||||
"createTag": "Cipta Tag",
|
||||
"tags": "Tag",
|
||||
"workerRoster": "Senarai Pekerja",
|
||||
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
|
||||
"filterByTag": "Tapis mengikut tag",
|
||||
"clearFilter": "Padam tapisan",
|
||||
"dateJoined": "Tarikh Sertai",
|
||||
"actions": "Tindakan",
|
||||
"editTags": "Sunting Tag",
|
||||
"viewRecords": "Lihat Rekod",
|
||||
"delete": "Padam",
|
||||
"loadingWorkers": "Memuatkan pekerja...",
|
||||
"noWorkersFound": "Tiada pekerja dijumpai.",
|
||||
"previous": "Sebelum",
|
||||
"next": "Seterusnya",
|
||||
"pageOf": "Halaman {current} daripada {total}",
|
||||
"noTagsAvailable": "Tiada tag tersedia.",
|
||||
"done": "Selesai",
|
||||
"bulkEditTags": "Sunting Tag Secara Berkumpulan",
|
||||
"clearSelection": "Padam Pilihan",
|
||||
"forUser": "Untuk pengguna",
|
||||
"savePassword": "Simpan Kata Laluan",
|
||||
"saving": "Menyimpan...",
|
||||
"failedToUpdateTags": "Gagal mengemas kini tag. Sila cuba lagi.",
|
||||
"tagDeleted": "Tag berjaya dipadam.",
|
||||
"failedToFetchWorkers": "Gagal memuatkan pekerja.",
|
||||
"failedToLoadPageData": "Gagal memuatkan data halaman.",
|
||||
"errorAddingUser": "Ralat semasa menambah pengguna.",
|
||||
"failedToDeleteWorker": "Gagal memadam pekerja.",
|
||||
"areYouSureDeleteWorker": "Adakah anda pasti mahu memadam akaun pekerja ini?",
|
||||
"areYouSureDeleteTag": "Adakah anda pasti mahu memadam tag ini? Ia akan dikeluarkan daripada semua pekerja.",
|
||||
"failedToDeleteTag": "Gagal memadam tag.",
|
||||
"passwordsDoNotMatch": "Kata laluan tidak sepadan.",
|
||||
"createQrCode": "Cipta Kod QR Baharu",
|
||||
"qrCodeName": "Nama Kod QR",
|
||||
"qrNamePlaceholder": "cth: 'Pintu Masuk Barat'",
|
||||
"create": "Cipta",
|
||||
"newCodeCreated": "Kod Baharu Telah Dicipta!",
|
||||
"saveQrInstruction": "Simpan imej ini atau gunakan ID di bawah. Ini akan hilang selepas segar semula.",
|
||||
"id": "ID",
|
||||
"existingQrCodes": "Kod QR Sedia Ada",
|
||||
"name": "Nama",
|
||||
"status": "Status",
|
||||
"active": "Aktif",
|
||||
"inactive": "Tidak Aktif",
|
||||
"deactivate": "Nyahaktif",
|
||||
"activate": "Aktifkan",
|
||||
"download": "Muat Turun",
|
||||
"noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!",
|
||||
"deleteQrConfirm": "Adakah anda pasti ingin memadam kod QR ini? Tindakan ini tidak boleh diundur.",
|
||||
"qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun.",
|
||||
|
||||
"rememberMe": "Ingat saya untuk log masuk automatik",
|
||||
"deviceNotAuthorized": "Peranti ini tidak dibenarkan untuk akaun anda. Sila hubungi pentadbir.",
|
||||
"locationTrackingActive": "Penjejakan lokasi aktif di latar belakang",
|
||||
"securityCheckInProgress": "Menjalankan pemeriksaan keselamatan...",
|
||||
"securityCheckComplete": "Pemeriksaan keselamatan selesai dengan jayanya",
|
||||
"highSecurityRisk": "Risiko keselamatan tinggi dikesan. Sila hubungi pentadbir anda.",
|
||||
"deviceRegistered": "Peranti berjaya didaftarkan",
|
||||
"autoLoginEnabled": "Log masuk automatik diaktifkan untuk peranti ini",
|
||||
"backgroundLocationEnabled": "Penjejakan lokasi latar belakang diaktifkan",
|
||||
"permissionsRequired": "Kebenaran lokasi diperlukan untuk penjejakan kehadiran",
|
||||
"batteryOptimizationWarning": "Sila lumpuhkan pengoptimuman bateri untuk aplikasi ini bagi memastikan penjejakan lokasi berterusan",
|
||||
"gpsSpooferDetected": "Aplikasi pemalsuan GPS dikesan. Ini mungkin menjejaskan ketepatan kehadiran.",
|
||||
"mockLocationEnabled": "Lokasi palsu diaktifkan. Sila lumpuhkannya untuk penjejakan kehadiran yang tepat.",
|
||||
"deviceSecurityWarning": "Amaran keselamatan peranti: Aplikasi mencurigakan dikesan",
|
||||
"locationUpdateFailed": "Gagal mengemas kini lokasi. Akan cuba semula secara automatik.",
|
||||
"servicesInitializing": "Memulakan perkhidmatan asli...",
|
||||
"servicesReady": "Semua perkhidmatan sedia",
|
||||
"autoLoginFailed": "Log masuk automatik gagal. Sila log masuk secara manual.",
|
||||
"deviceValidationFailed": "Pengesahan peranti gagal. Sila hubungi sokongan.",
|
||||
|
||||
"personal": "Peribadi",
|
||||
"clockHistory": "Sejarah Kehadiran",
|
||||
"openCamera": "Buka Kamera",
|
||||
"scanQRCode": "Imbas Kod QR",
|
||||
"services": "Perkhidmatan",
|
||||
"systemServicesStatus": "Status perkhidmatan sistem dan keselamatan",
|
||||
"updateYourPassword": "Kemaskini kata laluan akaun anda",
|
||||
"signOutOfAccount": "Log keluar dari akaun anda",
|
||||
|
||||
"servicesStatus": "Status Perkhidmatan",
|
||||
"overallStatus": "Status Keseluruhan",
|
||||
"locationTracking": "Penjejakan Lokasi",
|
||||
"deviceRegistration": "Pendaftaran Peranti",
|
||||
"securityStatus": "Status Keselamatan",
|
||||
"lastLocationUpdate": "Kemaskini Lokasi Terakhir",
|
||||
"deviceId": "ID Peranti",
|
||||
"start": "Mula",
|
||||
"check": "Periksa",
|
||||
"checking": "Memeriksa...",
|
||||
"refresh": "Segar Semula",
|
||||
"refreshing": "Menyegar Semula...",
|
||||
"notInitialized": "Tidak Dimulakan",
|
||||
"ready": "Sedia",
|
||||
"webOnly": "Web Sahaja",
|
||||
"registered": "Didaftarkan",
|
||||
"pending": "Menunggu",
|
||||
"notChecked": "Tidak Diperiksa",
|
||||
"outdated": "Lapuk",
|
||||
"current": "Semasa",
|
||||
"never": "Tidak Pernah",
|
||||
"justNow": "Baru Sahaja",
|
||||
"minutesAgo": "{minutes}m lalu",
|
||||
"hoursAgo": "{hours}j lalu",
|
||||
"daysAgo": "{days}h lalu",
|
||||
"failedToRefreshStatus": "Gagal menyegar semula status",
|
||||
"locationTrackingStarted": "Penjejakan lokasi dimulakan dengan jayanya",
|
||||
"failedToStartLocationTracking": "Gagal memulakan penjejakan lokasi",
|
||||
"securityCheckFailed": "Pemeriksaan keselamatan gagal"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import './assets/main.css'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import i18n from './i18n'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import WorkerDashboardView from '../views/WorkerDashboardView.vue'
|
||||
import WorkerHistoryView from '../views/WorkerHistoryView.vue'
|
||||
import ChangePasswordView from '../views/ChangePasswordView.vue'
|
||||
import PersonalView from '../views/PersonalView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'login', component: LoginView },
|
||||
{
|
||||
path: '/worker/dashboard',
|
||||
name: 'worker-dashboard',
|
||||
component: WorkerDashboardView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
{
|
||||
path: '/worker/history',
|
||||
name: 'worker-history',
|
||||
component: WorkerHistoryView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
{
|
||||
path: '/worker/change-password',
|
||||
name: 'worker-change-password',
|
||||
component: ChangePasswordView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
{
|
||||
path: '/worker/personal',
|
||||
name: 'worker-personal',
|
||||
component: PersonalView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// --- ALIGNMENT CHANGE: Navigation Guard ---
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isLoggedIn = !!sessionStorage.getItem('userId')
|
||||
const userRole = sessionStorage.getItem('userRole')
|
||||
|
||||
console.log('🛡️ ROUTER GUARD:', {
|
||||
to: to.path,
|
||||
from: from.path,
|
||||
isLoggedIn,
|
||||
userRole,
|
||||
requiresAuth: to.meta.requiresAuth,
|
||||
requiredRole: to.meta.role
|
||||
})
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
if (isLoggedIn) {
|
||||
// Check if user has the required role
|
||||
if (to.meta.role && to.meta.role === userRole) {
|
||||
console.log('✅ ACCESS GRANTED - Correct role')
|
||||
next() // User is logged in and has the correct role
|
||||
} else {
|
||||
// User is logged in but trying to access a page for another role
|
||||
// For worker client app, only redirect workers to worker dashboard
|
||||
if (userRole === 'worker') {
|
||||
console.log('🔄 REDIRECTING WORKER to dashboard')
|
||||
next('/worker/dashboard')
|
||||
} else {
|
||||
console.log('❌ NON-WORKER ACCESS DENIED - Worker client app only')
|
||||
next('/')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User is not logged in, redirect to login page
|
||||
console.log('❌ NOT LOGGED IN - Redirecting to login')
|
||||
next('/')
|
||||
}
|
||||
} else {
|
||||
// For public routes like the login page
|
||||
console.log('✅ PUBLIC ROUTE - Allowing access')
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,455 @@
|
||||
import { Device } from '@capacitor/device'
|
||||
import { AppLauncher } from '@capacitor/app-launcher'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
class AntiSpoofingService {
|
||||
constructor() {
|
||||
this.isNative = Capacitor.isNativePlatform()
|
||||
this.lastAppScanTime = null
|
||||
this.scanInterval = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
|
||||
|
||||
// Known GPS spoofing/mocking applications
|
||||
this.gpsSpooferBlacklist = [
|
||||
// Popular GPS spoofing apps
|
||||
'com.lexa.fakegps',
|
||||
'com.incorporateapps.fakegps.fre',
|
||||
'com.blogspot.newapphorizons.fakegps',
|
||||
'com.theappninjas.gpsjoystick',
|
||||
'com.fakegps.mock',
|
||||
'com.mock.location.app',
|
||||
'com.fakegps.location',
|
||||
'com.gpsemulator',
|
||||
'com.locationspoofer',
|
||||
'com.fakegps.pro',
|
||||
'com.mock.gps.location',
|
||||
'com.gps.mock.location',
|
||||
'com.fake.location.spoofer',
|
||||
'com.location.faker',
|
||||
'com.gps.faker',
|
||||
'com.mock.location.faker',
|
||||
'com.location.mock.gps',
|
||||
'com.gps.location.faker',
|
||||
'com.fake.gps.location.spoofer',
|
||||
'com.location.spoofer.gps',
|
||||
|
||||
// Developer/testing tools that can mock location
|
||||
'com.android.development',
|
||||
'com.android.development_settings',
|
||||
'com.android.settings.development',
|
||||
|
||||
// Root detection bypass tools (often used with GPS spoofing)
|
||||
'com.topjohnwu.magisk',
|
||||
'com.noshufou.android.su',
|
||||
'com.koushikdutta.superuser',
|
||||
'com.zachspong.temprootremovejb',
|
||||
'com.ramdroid.appquarantine',
|
||||
'com.devadvance.rootcloak',
|
||||
'com.devadvance.rootcloakplus',
|
||||
'de.robv.android.xposed.installer',
|
||||
'com.saurik.substrate',
|
||||
'com.amphoras.hidemyroot',
|
||||
'com.amphoras.hidemyrootadfree',
|
||||
'com.formyhm.hiderootPremium',
|
||||
'me.phh.superuser',
|
||||
'eu.chainfire.supersu',
|
||||
'com.kingouser.com',
|
||||
'com.android.vending.billing.InAppBillingService.LOCK',
|
||||
'com.android.vending.billing.InAppBillingService.LACK',
|
||||
|
||||
// Location simulation apps
|
||||
'com.hola.mocklocation',
|
||||
'com.lexa.fakegps.route',
|
||||
'com.fakegps.mock.location.app',
|
||||
'com.mock.location.app.free',
|
||||
'com.location.mock.free',
|
||||
'com.gps.mock.free'
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the anti-spoofing service
|
||||
*/
|
||||
async initialize() {
|
||||
if (!this.isNative) {
|
||||
console.warn('Anti-spoofing detection is only available on native platforms')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Anti-spoofing service initialized')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize anti-spoofing service:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform comprehensive security check
|
||||
*/
|
||||
|
||||
async performSecurityCheck() {
|
||||
try {
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
deviceInfo: await this.getDeviceInfo(),
|
||||
suspiciousApps: await this.detectSuspiciousApps(),
|
||||
locationSettings: await this.checkLocationSettings(),
|
||||
developerOptions: await this.checkDeveloperOptionsEnabled(),
|
||||
riskLevel: 'low'
|
||||
}
|
||||
|
||||
// Calculate risk level
|
||||
results.riskLevel = this.calculateRiskLevel(results)
|
||||
|
||||
// Send results to server
|
||||
this.lastAppScanTime = new Date()
|
||||
const finalResults = results; // Capture results before potential async operation
|
||||
|
||||
// Send results to server (fire and forget)
|
||||
void this.sendSecurityCheckToServer(finalResults);
|
||||
|
||||
return finalResults
|
||||
} catch (error) {
|
||||
console.error('Security check failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device information
|
||||
*/
|
||||
async getDeviceInfo() {
|
||||
try {
|
||||
const deviceInfo = await Device.getInfo()
|
||||
const deviceId = await Device.getId()
|
||||
|
||||
return {
|
||||
platform: deviceInfo.platform,
|
||||
model: deviceInfo.model,
|
||||
manufacturer: deviceInfo.manufacturer,
|
||||
osVersion: deviceInfo.osVersion,
|
||||
appVersion: deviceInfo.appVersion,
|
||||
deviceId: deviceId.identifier,
|
||||
isVirtual: deviceInfo.isVirtual || false,
|
||||
webViewVersion: deviceInfo.webViewVersion
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get device info:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect suspicious apps that might be used for GPS spoofing
|
||||
*/
|
||||
async detectSuspiciousApps() {
|
||||
try {
|
||||
// Note: Due to Android security restrictions, we cannot directly get a list of installed apps
|
||||
// Instead, we'll check if specific known spoofing apps can be launched
|
||||
const suspiciousApps = []
|
||||
|
||||
for (const packageName of this.gpsSpooferBlacklist) {
|
||||
try {
|
||||
// Try to check if the app can be opened (indicates it's installed)
|
||||
const canOpen = await AppLauncher.canOpenUrl({ url: `${packageName}://` })
|
||||
|
||||
if (canOpen.value) {
|
||||
suspiciousApps.push({
|
||||
packageName: packageName,
|
||||
detected: true,
|
||||
method: 'url_scheme_check'
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// App not found or cannot be checked - this is expected for most apps
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Additional checks for common spoofing indicators
|
||||
const additionalChecks = await this.performAdditionalSpoofingChecks()
|
||||
|
||||
return {
|
||||
suspiciousApps: suspiciousApps,
|
||||
additionalChecks: additionalChecks,
|
||||
totalSuspiciousApps: suspiciousApps.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to detect suspicious apps:', error)
|
||||
return {
|
||||
suspiciousApps: [],
|
||||
additionalChecks: {},
|
||||
totalSuspiciousApps: 0,
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform additional spoofing detection checks
|
||||
*/
|
||||
async performAdditionalSpoofingChecks() {
|
||||
const checks = {}
|
||||
|
||||
try {
|
||||
// Check if mock location is enabled (Android specific)
|
||||
// This would require a custom native plugin to properly detect
|
||||
checks.mockLocationEnabled = await this.checkMockLocationEnabled()
|
||||
|
||||
// Check for root indicators
|
||||
checks.rootIndicators = await this.checkRootIndicators()
|
||||
|
||||
// Check for developer options
|
||||
checks.developerOptionsEnabled = await this.checkDeveloperOptionsEnabled()
|
||||
|
||||
return checks
|
||||
} catch (error) {
|
||||
console.error('Additional spoofing checks failed:', error)
|
||||
return { error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mock location is enabled (placeholder - requires native implementation)
|
||||
*/
|
||||
async checkMockLocationEnabled() {
|
||||
// This would require a custom Capacitor plugin to properly implement
|
||||
// For now, we'll return a placeholder
|
||||
return {
|
||||
enabled: false,
|
||||
method: 'placeholder',
|
||||
note: 'Requires custom native plugin for accurate detection'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for root indicators
|
||||
*/
|
||||
async checkRootIndicators() {
|
||||
const indicators = []
|
||||
|
||||
try {
|
||||
// Check for common root-related package names
|
||||
const rootPackages = [
|
||||
'com.topjohnwu.magisk',
|
||||
'com.noshufou.android.su',
|
||||
'com.koushikdutta.superuser',
|
||||
'eu.chainfire.supersu'
|
||||
]
|
||||
|
||||
for (const packageName of rootPackages) {
|
||||
try {
|
||||
const canOpen = await AppLauncher.canOpenUrl({ url: `${packageName}://` })
|
||||
if (canOpen.value) {
|
||||
indicators.push(packageName)
|
||||
}
|
||||
} catch {
|
||||
// Expected for most devices
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detected: indicators.length > 0,
|
||||
indicators: indicators,
|
||||
count: indicators.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Root detection failed:', error)
|
||||
return { detected: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if developer options are enabled (placeholder)
|
||||
*/
|
||||
async checkDeveloperOptionsEnabled() {
|
||||
// This would require a custom native plugin to properly detect
|
||||
return {
|
||||
enabled: false,
|
||||
method: 'placeholder',
|
||||
note: 'Requires custom native plugin for accurate detection'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check location settings
|
||||
*/
|
||||
async checkLocationSettings() {
|
||||
try {
|
||||
// This would ideally check location provider settings
|
||||
// For now, we'll return basic information
|
||||
return {
|
||||
locationEnabled: true, // Placeholder
|
||||
highAccuracyEnabled: true, // Placeholder
|
||||
method: 'placeholder',
|
||||
note: 'Requires enhanced location permission checks'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Location settings check failed:', error)
|
||||
return { error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk level based on security check results
|
||||
*/
|
||||
calculateRiskLevel(results) {
|
||||
let riskScore = 0
|
||||
|
||||
// Suspicious apps detected
|
||||
if (results.suspiciousApps && results.suspiciousApps.totalSuspiciousApps > 0) {
|
||||
riskScore += results.suspiciousApps.totalSuspiciousApps * 10
|
||||
}
|
||||
|
||||
// Root indicators
|
||||
if (results.suspiciousApps && results.suspiciousApps.additionalChecks &&
|
||||
results.suspiciousApps.additionalChecks.rootIndicators &&
|
||||
results.suspiciousApps.additionalChecks.rootIndicators.detected) {
|
||||
riskScore += 20
|
||||
}
|
||||
|
||||
// Virtual device
|
||||
if (results.deviceInfo && results.deviceInfo.isVirtual) {
|
||||
riskScore += 15
|
||||
}
|
||||
|
||||
// Developer options enabled
|
||||
if (results.developerOptions && results.developerOptions.enabled) {
|
||||
riskScore += 10
|
||||
}
|
||||
|
||||
// Mock location enabled
|
||||
if (results.locationSettings && results.locationSettings.mockLocationEnabled) {
|
||||
riskScore += 25
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
if (riskScore >= 30) {
|
||||
return 'high'
|
||||
} else if (riskScore >= 15) {
|
||||
return 'medium'
|
||||
} else {
|
||||
return 'low'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send security check results to server
|
||||
*/
|
||||
async sendSecurityCheckToServer(results) {
|
||||
try {
|
||||
const userId = localStorage.getItem('userId') || sessionStorage.getItem('userId')
|
||||
|
||||
if (!userId) {
|
||||
console.warn('No user ID available for security check')
|
||||
return
|
||||
}
|
||||
|
||||
const securityData = {
|
||||
userId: userId,
|
||||
timestamp: results.timestamp,
|
||||
deviceInfo: results.deviceInfo,
|
||||
securityCheck: {
|
||||
suspiciousApps: results.suspiciousApps,
|
||||
locationSettings: results.locationSettings,
|
||||
developerOptions: results.developerOptions,
|
||||
riskLevel: results.riskLevel
|
||||
}
|
||||
}
|
||||
|
||||
await apiFetch('/api/security/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(securityData)
|
||||
})
|
||||
|
||||
console.log('Security check sent to server successfully')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send security check to server:', error)
|
||||
// Store for retry later
|
||||
this.storeSecurityCheckForRetry(results)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store security check for retry
|
||||
*/
|
||||
storeSecurityCheckForRetry(results) {
|
||||
try {
|
||||
const pendingChecks = JSON.parse(localStorage.getItem('pendingSecurityChecks') || '[]')
|
||||
pendingChecks.push({
|
||||
results,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Keep only last 10 pending checks
|
||||
if (pendingChecks.length > 10) {
|
||||
pendingChecks.splice(0, pendingChecks.length - 10)
|
||||
}
|
||||
|
||||
localStorage.setItem('pendingSecurityChecks', JSON.stringify(pendingChecks))
|
||||
} catch (error) {
|
||||
console.error('Failed to store security check for retry:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry pending security checks
|
||||
*/
|
||||
async retryPendingSecurityChecks() {
|
||||
try {
|
||||
const pendingChecks = JSON.parse(localStorage.getItem('pendingSecurityChecks') || '[]')
|
||||
|
||||
if (pendingChecks.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Retrying ${pendingChecks.length} pending security checks`)
|
||||
|
||||
for (const pendingCheck of pendingChecks) {
|
||||
await this.sendSecurityCheckToServer(pendingCheck.results).catch(error => {
|
||||
console.error('Failed to retry security check:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// Clear successfully sent checks
|
||||
localStorage.removeItem('pendingSecurityChecks')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to retry pending security checks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if security scan is needed
|
||||
*/
|
||||
shouldPerformSecurityCheck() {
|
||||
if (!this.lastAppScanTime) {
|
||||
return true
|
||||
}
|
||||
|
||||
const timeSinceLastScan = Date.now() - this.lastAppScanTime.getTime()
|
||||
return timeSinceLastScan >= this.scanInterval
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last security check time
|
||||
*/
|
||||
getLastSecurityCheckTime() {
|
||||
return this.lastAppScanTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a security check regardless of timing
|
||||
*/
|
||||
async forceSecurityCheck() {
|
||||
return await this.performSecurityCheck()
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const antiSpoofingService = new AntiSpoofingService()
|
||||
export default antiSpoofingService
|
||||
@@ -0,0 +1,402 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.isNative = Capacitor.isNativePlatform()
|
||||
this.tokenKey = 'auth_token'
|
||||
this.userIdKey = 'user_id'
|
||||
this.userRoleKey = 'user_role'
|
||||
this.credentialsKey = 'user_credentials'
|
||||
this.deviceUuidKey = 'device_uuid'
|
||||
this.autoLoginEnabledKey = 'auto_login_enabled'
|
||||
}
|
||||
|
||||
/**
|
||||
* Store authentication data securely
|
||||
*/
|
||||
async storeAuthData(token, userId, userRole, rememberCredentials = false, credentials = null) {
|
||||
try {
|
||||
// Store basic auth data
|
||||
await this.setSecureItem(this.tokenKey, token)
|
||||
await this.setSecureItem(this.userIdKey, userId.toString())
|
||||
await this.setSecureItem(this.userRoleKey, userRole)
|
||||
|
||||
// Store credentials for auto-login if requested
|
||||
if (rememberCredentials && credentials) {
|
||||
await this.setSecureItem(this.credentialsKey, JSON.stringify(credentials))
|
||||
await this.setSecureItem(this.autoLoginEnabledKey, 'true')
|
||||
}
|
||||
|
||||
// Also store in sessionStorage for web compatibility
|
||||
if (!this.isNative) {
|
||||
sessionStorage.setItem('token', token)
|
||||
sessionStorage.setItem('userId', userId.toString())
|
||||
sessionStorage.setItem('userRole', userRole)
|
||||
}
|
||||
|
||||
console.log('Authentication data stored successfully')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to store authentication data:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication token
|
||||
*/
|
||||
async getAuthToken() {
|
||||
try {
|
||||
const token = await this.getSecureItem(this.tokenKey)
|
||||
|
||||
// Fallback to sessionStorage for web
|
||||
if (!token && !this.isNative) {
|
||||
return sessionStorage.getItem('token')
|
||||
}
|
||||
|
||||
return token
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth token:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored user ID
|
||||
*/
|
||||
async getUserId() {
|
||||
try {
|
||||
const userId = await this.getSecureItem(this.userIdKey)
|
||||
|
||||
// Fallback to sessionStorage for web
|
||||
if (!userId && !this.isNative) {
|
||||
return sessionStorage.getItem('userId')
|
||||
}
|
||||
|
||||
return userId
|
||||
} catch (error) {
|
||||
console.error('Failed to get user ID:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored user role
|
||||
*/
|
||||
async getUserRole() {
|
||||
try {
|
||||
const userRole = await this.getSecureItem(this.userRoleKey)
|
||||
|
||||
// Fallback to sessionStorage for web
|
||||
if (!userRole && !this.isNative) {
|
||||
return sessionStorage.getItem('userRole')
|
||||
}
|
||||
|
||||
return userRole
|
||||
} catch (error) {
|
||||
console.error('Failed to get user role:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored credentials for auto-login
|
||||
*/
|
||||
async getStoredCredentials() {
|
||||
try {
|
||||
const autoLoginEnabled = await this.getSecureItem(this.autoLoginEnabledKey)
|
||||
|
||||
if (autoLoginEnabled !== 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
const credentialsStr = await this.getSecureItem(this.credentialsKey)
|
||||
|
||||
if (!credentialsStr) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(credentialsStr)
|
||||
} catch (error) {
|
||||
console.error('Failed to get stored credentials:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently authenticated
|
||||
*/
|
||||
async isAuthenticated() {
|
||||
try {
|
||||
const token = await this.getAuthToken()
|
||||
|
||||
if (!token) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify token is not expired
|
||||
return this.isTokenValid(token)
|
||||
} catch (error) {
|
||||
console.error('Failed to check authentication status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt auto-login using stored credentials
|
||||
*/
|
||||
async attemptAutoLogin() {
|
||||
try {
|
||||
// First check if we have a valid token
|
||||
const isAuth = await this.isAuthenticated()
|
||||
if (isAuth) {
|
||||
console.log('User already authenticated with valid token')
|
||||
return {
|
||||
success: true,
|
||||
userId: await this.getUserId(),
|
||||
userRole: await this.getUserRole()
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get stored credentials
|
||||
const credentials = await this.getStoredCredentials()
|
||||
|
||||
if (!credentials) {
|
||||
console.log('No stored credentials found for auto-login')
|
||||
return { success: false, reason: 'no_credentials' }
|
||||
}
|
||||
|
||||
console.log('Attempting auto-login with stored credentials')
|
||||
|
||||
// Attempt login with stored credentials
|
||||
const loginResult = await this.login(credentials.username, credentials.password, false)
|
||||
|
||||
if (loginResult.success) {
|
||||
console.log('Auto-login successful')
|
||||
return loginResult
|
||||
} else {
|
||||
console.log('Auto-login failed, clearing stored credentials')
|
||||
await this.clearStoredCredentials()
|
||||
return { success: false, reason: 'invalid_credentials' }
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Auto-login failed:', error)
|
||||
return { success: false, reason: 'error', error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
async login(username, password, rememberCredentials = false) {
|
||||
try {
|
||||
// Import deviceUuidService for proper device-specific UUID generation
|
||||
const { deviceUuidService } = await import('./deviceUuidService.js')
|
||||
const deviceUuid = await deviceUuidService.getOrCreateDeviceUuid()
|
||||
|
||||
const loginUrl = `${import.meta.env.VITE_API_BASE_URL}/api/auth/login`
|
||||
console.log('Attempting to fetch from:', loginUrl)
|
||||
const response = await fetch(loginUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'ngrok-skip-browser-warning': 'true'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
deviceUuid // Include device UUID in login request
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Decode token to get user info
|
||||
const decodedToken = JSON.parse(atob(data.token.split('.')[1]))
|
||||
|
||||
// Store authentication data
|
||||
const credentials = rememberCredentials ? { username, password } : null
|
||||
await this.storeAuthData(
|
||||
data.token,
|
||||
decodedToken.userId,
|
||||
decodedToken.role,
|
||||
rememberCredentials,
|
||||
credentials
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
userId: decodedToken.userId,
|
||||
userRole: decodedToken.role,
|
||||
token: data.token
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || 'Login failed'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: 'Network error or server unavailable'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear all stored data
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
// Clear secure storage
|
||||
await this.removeSecureItem(this.tokenKey)
|
||||
await this.removeSecureItem(this.userIdKey)
|
||||
await this.removeSecureItem(this.userRoleKey)
|
||||
|
||||
// Clear session storage for web compatibility
|
||||
if (!this.isNative) {
|
||||
sessionStorage.removeItem('token')
|
||||
sessionStorage.removeItem('userId')
|
||||
sessionStorage.removeItem('userRole')
|
||||
}
|
||||
|
||||
console.log('Logout successful')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to logout:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored credentials (but keep current session)
|
||||
*/
|
||||
async clearStoredCredentials() {
|
||||
try {
|
||||
await this.removeSecureItem(this.credentialsKey)
|
||||
await this.removeSecureItem(this.autoLoginEnabledKey)
|
||||
console.log('Stored credentials cleared')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to clear stored credentials:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable auto-login
|
||||
*/
|
||||
async setAutoLoginEnabled(enabled) {
|
||||
try {
|
||||
if (enabled) {
|
||||
await this.setSecureItem(this.autoLoginEnabledKey, 'true')
|
||||
} else {
|
||||
await this.clearStoredCredentials()
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to set auto-login preference:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-login is enabled
|
||||
*/
|
||||
async isAutoLoginEnabled() {
|
||||
try {
|
||||
const enabled = await this.getSecureItem(this.autoLoginEnabledKey)
|
||||
return enabled === 'true'
|
||||
} catch (error) {
|
||||
console.error('Failed to check auto-login status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Device UUID generation is now handled by deviceUuidService.js
|
||||
// This ensures device-specific, deterministic UUID generation based on hardware characteristics
|
||||
|
||||
/**
|
||||
* Check if token is valid (not expired)
|
||||
*/
|
||||
isTokenValid(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
const currentTime = Date.now() / 1000
|
||||
|
||||
return payload.exp > currentTime
|
||||
} catch (error) {
|
||||
console.error('Failed to validate token:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
*/
|
||||
async refreshToken() {
|
||||
try {
|
||||
const token = await this.getAuthToken()
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No token available for refresh')
|
||||
}
|
||||
|
||||
const response = await apiFetch('/api/auth/refresh', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response && response.token) {
|
||||
await this.setSecureItem(this.tokenKey, response.token)
|
||||
|
||||
// Update sessionStorage for web compatibility
|
||||
if (!this.isNative) {
|
||||
sessionStorage.setItem('token', response.token)
|
||||
}
|
||||
|
||||
return response.token
|
||||
}
|
||||
|
||||
throw new Error('Token refresh failed')
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for secure storage
|
||||
async setSecureItem(key, value) {
|
||||
if (this.isNative) {
|
||||
await Preferences.set({ key, value })
|
||||
} else {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
async getSecureItem(key) {
|
||||
if (this.isNative) {
|
||||
const result = await Preferences.get({ key })
|
||||
return result.value
|
||||
} else {
|
||||
return localStorage.getItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
async removeSecureItem(key) {
|
||||
if (this.isNative) {
|
||||
await Preferences.remove({ key })
|
||||
} else {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const authService = new AuthService()
|
||||
export default authService
|
||||
@@ -0,0 +1,362 @@
|
||||
import { registerPlugin } from '@capacitor/core'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
const BackgroundGeolocation = registerPlugin('BackgroundGeolocation')
|
||||
|
||||
class BackgroundLocationService {
|
||||
constructor() {
|
||||
this.isInitialized = false
|
||||
this.isTracking = false
|
||||
this.locationUpdateInterval = 30 * 60 * 1000 // 30 minutes in milliseconds
|
||||
this.lastLocationUpdate = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the background location service
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Check if geolocation is available
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
console.warn('Background location limited on web platform')
|
||||
}
|
||||
|
||||
// Request location permissions first
|
||||
const permissions = await Geolocation.requestPermissions()
|
||||
|
||||
if (permissions.location !== 'granted') {
|
||||
console.error('Location permission not granted')
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('Location permissions granted:', permissions)
|
||||
|
||||
// Initialize background geolocation with community plugin
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
try {
|
||||
this.watcherId = await BackgroundGeolocation.addWatcher(
|
||||
{
|
||||
// Location request options
|
||||
requestPermissions: true,
|
||||
stale: false,
|
||||
distanceFilter: 10, // meters
|
||||
},
|
||||
(location, error) => {
|
||||
if (error) {
|
||||
console.error('Background location error:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (location) {
|
||||
console.log('Background location update:', location)
|
||||
this.onLocationUpdate(location)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
console.log('Background geolocation watcher added with ID:', this.watcherId)
|
||||
} catch (error) {
|
||||
console.warn('Background geolocation not available, using fallback:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.isInitialized = true
|
||||
console.log('Background location service initialized successfully')
|
||||
return true
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize background location service:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for location updates
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// The community plugin uses watchers instead of event listeners
|
||||
// Location updates are handled in the watcher callback
|
||||
console.log('Event listeners set up (using watcher callback)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Start location tracking
|
||||
*/
|
||||
async startTracking() {
|
||||
if (!this.isInitialized) {
|
||||
const initialized = await this.initialize()
|
||||
if (!initialized) {
|
||||
throw new Error('Failed to initialize background location service')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// The community plugin starts tracking when watcher is added
|
||||
// If watcher is already added, tracking is already active
|
||||
if (this.watcherId) {
|
||||
this.isTracking = true
|
||||
console.log('Background location tracking started (watcher active)')
|
||||
} else {
|
||||
// Re-initialize if watcher was removed
|
||||
await this.initialize()
|
||||
this.isTracking = true
|
||||
}
|
||||
|
||||
// Start periodic location updates
|
||||
this.startPeriodicUpdates()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to start location tracking:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop location tracking
|
||||
*/
|
||||
async stopTracking() {
|
||||
try {
|
||||
if (this.watcherId) {
|
||||
await BackgroundGeolocation.removeWatcher({ id: this.watcherId })
|
||||
this.watcherId = null
|
||||
}
|
||||
|
||||
this.isTracking = false
|
||||
console.log('Background location tracking stopped')
|
||||
|
||||
// Stop periodic updates
|
||||
this.stopPeriodicUpdates()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to stop location tracking:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic location updates every 30 minutes
|
||||
*/
|
||||
startPeriodicUpdates() {
|
||||
if (this.periodicUpdateTimer) {
|
||||
clearInterval(this.periodicUpdateTimer)
|
||||
}
|
||||
|
||||
this.periodicUpdateTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.getCurrentLocationAndSend()
|
||||
} catch (error) {
|
||||
console.error('Periodic location update failed:', error)
|
||||
}
|
||||
}, this.locationUpdateInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic location updates
|
||||
*/
|
||||
stopPeriodicUpdates() {
|
||||
if (this.periodicUpdateTimer) {
|
||||
clearInterval(this.periodicUpdateTimer)
|
||||
this.periodicUpdateTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current location and send to server
|
||||
*/
|
||||
async getCurrentLocationAndSend() {
|
||||
try {
|
||||
const position = await Geolocation.getCurrentPosition({
|
||||
timeout: 30000,
|
||||
maximumAge: 5000,
|
||||
enableHighAccuracy: true
|
||||
})
|
||||
|
||||
// Convert to format expected by sendLocationToServer
|
||||
const location = {
|
||||
coords: {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
speed: position.coords.speed,
|
||||
heading: position.coords.heading,
|
||||
altitude: position.coords.altitude
|
||||
},
|
||||
timestamp: position.timestamp
|
||||
}
|
||||
|
||||
await this.sendLocationToServer(location)
|
||||
return location
|
||||
} catch (error) {
|
||||
console.error('Failed to get current location:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send location data to server
|
||||
*/
|
||||
async sendLocationToServer(location) {
|
||||
try {
|
||||
const token = localStorage.getItem('token') || sessionStorage.getItem('token')
|
||||
const userId = localStorage.getItem('userId') || sessionStorage.getItem('userId')
|
||||
|
||||
if (!token || !userId) {
|
||||
console.warn('No authentication token or user ID available')
|
||||
return
|
||||
}
|
||||
|
||||
const locationData = {
|
||||
userId: userId,
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy: location.coords.accuracy,
|
||||
timestamp: new Date(location.timestamp).toISOString(),
|
||||
speed: location.coords.speed || 0,
|
||||
heading: location.coords.heading || 0,
|
||||
altitude: location.coords.altitude || 0
|
||||
}
|
||||
|
||||
await apiFetch('/api/location/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(locationData)
|
||||
})
|
||||
|
||||
this.lastLocationUpdate = new Date()
|
||||
console.log('Location sent to server successfully:', locationData)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send location to server:', error)
|
||||
// Store location locally for retry later if needed
|
||||
this.storeLocationForRetry(location)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store location data locally for retry
|
||||
*/
|
||||
storeLocationForRetry(location) {
|
||||
try {
|
||||
const pendingLocations = JSON.parse(localStorage.getItem('pendingLocationUpdates') || '[]')
|
||||
pendingLocations.push({
|
||||
location,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Keep only last 50 pending locations to avoid storage bloat
|
||||
if (pendingLocations.length > 50) {
|
||||
pendingLocations.splice(0, pendingLocations.length - 50)
|
||||
}
|
||||
|
||||
localStorage.setItem('pendingLocationUpdates', JSON.stringify(pendingLocations))
|
||||
} catch (error) {
|
||||
console.error('Failed to store location for retry:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry sending pending location updates
|
||||
*/
|
||||
async retryPendingLocationUpdates() {
|
||||
try {
|
||||
const pendingLocations = JSON.parse(localStorage.getItem('pendingLocationUpdates') || '[]')
|
||||
|
||||
if (pendingLocations.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Retrying ${pendingLocations.length} pending location updates`)
|
||||
|
||||
for (const pendingLocation of pendingLocations) {
|
||||
try {
|
||||
await this.sendLocationToServer(pendingLocation.location)
|
||||
} catch (error) {
|
||||
console.error('Failed to retry location update:', error)
|
||||
// Keep failed updates for next retry
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Clear successfully sent locations
|
||||
localStorage.removeItem('pendingLocationUpdates')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to retry pending location updates:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
onLocationUpdate(location) {
|
||||
console.log('Location update received:', location)
|
||||
// Location is automatically sent to server via HTTP config
|
||||
// But we can also handle it manually if needed
|
||||
}
|
||||
|
||||
onLocationError(error) {
|
||||
console.error('Location error:', error)
|
||||
}
|
||||
|
||||
onMotionChange(event) {
|
||||
console.log('Motion change:', event)
|
||||
}
|
||||
|
||||
onActivityChange(event) {
|
||||
console.log('Activity change:', event)
|
||||
}
|
||||
|
||||
onHttpSuccess(response) {
|
||||
console.log('HTTP success:', response)
|
||||
}
|
||||
|
||||
onHttpFailure(response) {
|
||||
console.error('HTTP failure:', response)
|
||||
// Handle HTTP failures - maybe store for retry
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if location tracking is currently active
|
||||
*/
|
||||
isLocationTrackingActive() {
|
||||
return this.isTracking
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last location update timestamp
|
||||
*/
|
||||
getLastLocationUpdateTime() {
|
||||
return this.lastLocationUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Request location permissions
|
||||
*/
|
||||
async requestLocationPermissions() {
|
||||
try {
|
||||
const status = await BackgroundGeolocation.requestPermission()
|
||||
return status
|
||||
} catch (error) {
|
||||
console.error('Failed to request location permissions:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check location permission status
|
||||
*/
|
||||
async getLocationPermissionStatus() {
|
||||
try {
|
||||
const status = await BackgroundGeolocation.getProviderState()
|
||||
return status
|
||||
} catch (error) {
|
||||
console.error('Failed to get location permission status:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const backgroundLocationService = new BackgroundLocationService()
|
||||
export default backgroundLocationService
|
||||
@@ -0,0 +1,445 @@
|
||||
import { Device } from '@capacitor/device'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { apiFetch } from '@/api.js'
|
||||
import { authService } from '@/services/authService.js'
|
||||
|
||||
class DeviceUuidService {
|
||||
constructor() {
|
||||
this.isNative = Capacitor.isNativePlatform()
|
||||
this.deviceUuidKey = 'device_uuid'
|
||||
this.deviceInfoKey = 'device_info'
|
||||
this.deviceRegistrationKey = 'device_registered'
|
||||
this.cachedDeviceUuid = null
|
||||
this.cachedDeviceInfo = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the device UUID service
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Get or create device UUID
|
||||
this.cachedDeviceUuid = await this.getOrCreateDeviceUuid()
|
||||
|
||||
// Get device information
|
||||
this.cachedDeviceInfo = await this.getDeviceInfo()
|
||||
|
||||
console.log('Device UUID service initialized with UUID:', this.cachedDeviceUuid)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize device UUID service:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a persistent device UUID
|
||||
*/
|
||||
async getOrCreateDeviceUuid() {
|
||||
try {
|
||||
// First try to get existing UUID from secure storage
|
||||
let deviceUuid = await this.getSecureItem(this.deviceUuidKey)
|
||||
|
||||
if (deviceUuid) {
|
||||
console.log('Found existing device UUID:', deviceUuid)
|
||||
return deviceUuid
|
||||
}
|
||||
|
||||
// If no UUID exists, create a new one
|
||||
deviceUuid = await this.generateDeviceUuid()
|
||||
|
||||
// Store the new UUID securely
|
||||
await this.setSecureItem(this.deviceUuidKey, deviceUuid)
|
||||
|
||||
console.log('Generated new device UUID:', deviceUuid)
|
||||
return deviceUuid
|
||||
} catch (error) {
|
||||
console.error('Failed to get/create device UUID:', error)
|
||||
// Fallback to a session-based UUID
|
||||
return this.generateFallbackUuid()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a device-specific UUID
|
||||
*/
|
||||
async generateDeviceUuid() {
|
||||
try {
|
||||
let baseString = ''
|
||||
|
||||
if (this.isNative) {
|
||||
// Get device-specific information for UUID generation
|
||||
const deviceInfo = await Device.getInfo()
|
||||
const deviceId = await Device.getId()
|
||||
|
||||
// Create a base string from device characteristics
|
||||
baseString = [
|
||||
deviceInfo.platform || 'unknown',
|
||||
deviceInfo.model || 'unknown',
|
||||
deviceInfo.manufacturer || 'unknown',
|
||||
deviceId.identifier || 'unknown',
|
||||
deviceInfo.osVersion || 'unknown'
|
||||
].join('-')
|
||||
} else {
|
||||
// Web fallback - use browser characteristics
|
||||
baseString = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
screen.width,
|
||||
screen.height,
|
||||
new Date().getTimezoneOffset()
|
||||
].join('-')
|
||||
}
|
||||
|
||||
// Generate UUID based on device characteristics
|
||||
const uuid = await this.hashStringToUuid(baseString)
|
||||
|
||||
return uuid
|
||||
} catch (error) {
|
||||
console.error('Failed to generate device UUID:', error)
|
||||
return this.generateFallbackUuid()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a string to create a UUID-like identifier
|
||||
*/
|
||||
async hashStringToUuid(str) {
|
||||
try {
|
||||
// Simple hash function to create consistent UUID from string
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
// Convert hash to UUID format
|
||||
const hashStr = Math.abs(hash).toString(16).padStart(8, '0')
|
||||
const timestamp = Date.now().toString(16).slice(-8)
|
||||
const random = Math.random().toString(16).slice(2, 10)
|
||||
|
||||
return `${hashStr.slice(0, 8)}-${hashStr.slice(0, 4)}-4${hashStr.slice(1, 4)}-${timestamp.slice(0, 4)}-${random}`
|
||||
} catch (error) {
|
||||
console.error('Failed to hash string to UUID:', error)
|
||||
return this.generateFallbackUuid()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fallback UUID
|
||||
*/
|
||||
generateFallbackUuid() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0
|
||||
const v = c == 'x' ? r : (r & 0x3 | 0x8)
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive device information
|
||||
*/
|
||||
async getDeviceInfo() {
|
||||
try {
|
||||
if (!this.isNative) {
|
||||
return this.getWebDeviceInfo()
|
||||
}
|
||||
|
||||
const deviceInfo = await Device.getInfo()
|
||||
const deviceId = await Device.getId()
|
||||
|
||||
const info = {
|
||||
uuid: this.cachedDeviceUuid || await this.getOrCreateDeviceUuid(),
|
||||
platform: deviceInfo.platform,
|
||||
model: deviceInfo.model,
|
||||
manufacturer: deviceInfo.manufacturer,
|
||||
osVersion: deviceInfo.osVersion,
|
||||
appVersion: deviceInfo.appVersion,
|
||||
deviceId: deviceId.identifier,
|
||||
isVirtual: deviceInfo.isVirtual || false,
|
||||
webViewVersion: deviceInfo.webViewVersion,
|
||||
memUsed: deviceInfo.memUsed,
|
||||
diskFree: deviceInfo.diskFree,
|
||||
diskTotal: deviceInfo.diskTotal,
|
||||
batteryLevel: await this.getBatteryLevel(),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Cache device info
|
||||
await this.setSecureItem(this.deviceInfoKey, JSON.stringify(info))
|
||||
|
||||
return info
|
||||
} catch (error) {
|
||||
console.error('Failed to get device info:', error)
|
||||
return this.getBasicDeviceInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get web-based device information
|
||||
*/
|
||||
getWebDeviceInfo() {
|
||||
return {
|
||||
uuid: this.cachedDeviceUuid,
|
||||
platform: 'web',
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
screenWidth: screen.width,
|
||||
screenHeight: screen.height,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
cookieEnabled: navigator.cookieEnabled,
|
||||
onLine: navigator.onLine,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic device information as fallback
|
||||
*/
|
||||
getBasicDeviceInfo() {
|
||||
return {
|
||||
uuid: this.cachedDeviceUuid,
|
||||
platform: this.isNative ? 'mobile' : 'web',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: 'Failed to get detailed device information'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Register device with server
|
||||
*/
|
||||
async registerDeviceWithServer() {
|
||||
try {
|
||||
const deviceInfo = await this.getDeviceInfo()
|
||||
|
||||
// Get user ID from auth service instead of direct storage access
|
||||
const userId = await authService.getUserId()
|
||||
const userRole = await authService.getUserRole()
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('No user ID available for device registration')
|
||||
}
|
||||
|
||||
// Skip device registration for managers - they don't have device restrictions
|
||||
if (userRole === 'manager') {
|
||||
console.log('Skipping device registration for manager role')
|
||||
await this.setSecureItem(this.deviceRegistrationKey, 'true')
|
||||
return true
|
||||
}
|
||||
|
||||
const registrationData = {
|
||||
userId: userId,
|
||||
deviceUuid: deviceInfo.uuid,
|
||||
deviceInfo: deviceInfo
|
||||
}
|
||||
|
||||
const response = await apiFetch('/api/device/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(registrationData)
|
||||
})
|
||||
|
||||
if (response && response.success) {
|
||||
await this.setSecureItem(this.deviceRegistrationKey, 'true')
|
||||
console.log('Device registered successfully with server')
|
||||
return true
|
||||
} else {
|
||||
throw new Error(response?.message || 'Device registration failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to register device with server:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate device with server
|
||||
*/
|
||||
async validateDeviceWithServer() {
|
||||
try {
|
||||
const deviceUuid = await this.getDeviceUuid()
|
||||
|
||||
// Get user ID from auth service instead of direct storage access
|
||||
const userId = await authService.getUserId()
|
||||
const userRole = await authService.getUserRole()
|
||||
|
||||
if (!userId || !deviceUuid) {
|
||||
throw new Error('Missing user ID or device UUID for validation')
|
||||
}
|
||||
|
||||
// Skip device validation for managers - they don't have device restrictions
|
||||
if (userRole === 'manager') {
|
||||
console.log('Skipping device validation for manager role')
|
||||
return {
|
||||
valid: true,
|
||||
message: 'Manager role - device validation bypassed'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiFetch('/api/device/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: userId,
|
||||
deviceUuid: deviceUuid
|
||||
})
|
||||
})
|
||||
|
||||
if (response && response.valid) {
|
||||
console.log('Device validation successful')
|
||||
return {
|
||||
valid: true,
|
||||
message: response.message
|
||||
}
|
||||
} else {
|
||||
console.warn('Device validation failed:', response?.message)
|
||||
return {
|
||||
valid: false,
|
||||
message: response?.message || 'Device validation failed'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Device validation error:', error)
|
||||
return {
|
||||
valid: false,
|
||||
message: error.message,
|
||||
error: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is registered
|
||||
*/
|
||||
async isDeviceRegistered() {
|
||||
try {
|
||||
const registered = await this.getSecureItem(this.deviceRegistrationKey)
|
||||
return registered === 'true'
|
||||
} catch (error) {
|
||||
console.error('Failed to check device registration status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current device UUID
|
||||
*/
|
||||
async getDeviceUuid() {
|
||||
if (this.cachedDeviceUuid) {
|
||||
return this.cachedDeviceUuid
|
||||
}
|
||||
|
||||
this.cachedDeviceUuid = await this.getOrCreateDeviceUuid()
|
||||
return this.cachedDeviceUuid
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached device info
|
||||
*/
|
||||
async getCachedDeviceInfo() {
|
||||
if (this.cachedDeviceInfo) {
|
||||
return this.cachedDeviceInfo
|
||||
}
|
||||
|
||||
try {
|
||||
const cachedInfo = await this.getSecureItem(this.deviceInfoKey)
|
||||
if (cachedInfo) {
|
||||
this.cachedDeviceInfo = JSON.parse(cachedInfo)
|
||||
return this.cachedDeviceInfo
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached device info:', error)
|
||||
}
|
||||
|
||||
this.cachedDeviceInfo = await this.getDeviceInfo()
|
||||
return this.cachedDeviceInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset device UUID (for testing or troubleshooting)
|
||||
*/
|
||||
async resetDeviceUuid() {
|
||||
try {
|
||||
await this.removeSecureItem(this.deviceUuidKey)
|
||||
await this.removeSecureItem(this.deviceInfoKey)
|
||||
await this.removeSecureItem(this.deviceRegistrationKey)
|
||||
|
||||
this.cachedDeviceUuid = null
|
||||
this.cachedDeviceInfo = null
|
||||
|
||||
// Generate new UUID
|
||||
this.cachedDeviceUuid = await this.getOrCreateDeviceUuid()
|
||||
|
||||
console.log('Device UUID reset successfully. New UUID:', this.cachedDeviceUuid)
|
||||
return this.cachedDeviceUuid
|
||||
} catch (error) {
|
||||
console.error('Failed to reset device UUID:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send device heartbeat to server
|
||||
*/
|
||||
async sendDeviceHeartbeat() {
|
||||
try {
|
||||
const deviceInfo = await this.getCachedDeviceInfo()
|
||||
|
||||
// Get user ID from auth service instead of direct storage access
|
||||
const userId = await authService.getUserId()
|
||||
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const heartbeatData = {
|
||||
userId: userId,
|
||||
deviceUuid: deviceInfo.uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
isOnline: navigator.onLine
|
||||
}
|
||||
|
||||
await apiFetch('/api/device/heartbeat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(heartbeatData)
|
||||
})
|
||||
|
||||
console.log('Device heartbeat sent successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to send device heartbeat:', error)
|
||||
// Don't throw error for heartbeat failures
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for secure storage
|
||||
async setSecureItem(key, value) {
|
||||
if (this.isNative) {
|
||||
await Preferences.set({ key, value })
|
||||
} else {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
async getSecureItem(key) {
|
||||
if (this.isNative) {
|
||||
const result = await Preferences.get({ key })
|
||||
return result.value
|
||||
} else {
|
||||
return localStorage.getItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
async removeSecureItem(key) {
|
||||
if (this.isNative) {
|
||||
await Preferences.remove({ key })
|
||||
} else {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const deviceUuidService = new DeviceUuidService()
|
||||
export default deviceUuidService
|
||||
@@ -0,0 +1,359 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { authService } from './authService.js'
|
||||
import { backgroundLocationService } from './backgroundLocationService.js'
|
||||
import { antiSpoofingService } from './antiSpoofingService.js'
|
||||
import { deviceUuidService } from './deviceUuidService.js'
|
||||
|
||||
class NativeServicesManager {
|
||||
constructor() {
|
||||
this.isNative = Capacitor.isNativePlatform()
|
||||
this.isInitialized = false
|
||||
this.services = {
|
||||
auth: authService,
|
||||
location: backgroundLocationService,
|
||||
antiSpoofing: antiSpoofingService,
|
||||
deviceUuid: deviceUuidService
|
||||
}
|
||||
this.initializationPromise = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all native services
|
||||
*/
|
||||
async initialize() {
|
||||
if (this.initializationPromise) {
|
||||
return this.initializationPromise
|
||||
}
|
||||
|
||||
this.initializationPromise = this._performInitialization()
|
||||
return this.initializationPromise
|
||||
}
|
||||
|
||||
async _performInitialization() {
|
||||
if (this.isInitialized) {
|
||||
return true
|
||||
}
|
||||
|
||||
console.log('Initializing native services manager...')
|
||||
|
||||
try {
|
||||
// Initialize services in order of dependency
|
||||
const results = {}
|
||||
|
||||
// 1. Initialize device UUID service first (needed by others)
|
||||
console.log('Initializing device UUID service...')
|
||||
results.deviceUuid = await this.services.deviceUuid.initialize()
|
||||
|
||||
// 2. Initialize auth service
|
||||
console.log('Initializing auth service...')
|
||||
results.auth = true // Auth service doesn't need explicit initialization
|
||||
|
||||
// 3. Initialize anti-spoofing service
|
||||
console.log('Initializing anti-spoofing service...')
|
||||
results.antiSpoofing = await this.services.antiSpoofing.initialize()
|
||||
|
||||
// 4. Initialize background location service (only if authenticated)
|
||||
console.log('Initializing background location service...')
|
||||
results.location = await this.services.location.initialize()
|
||||
|
||||
// Log initialization results
|
||||
console.log('Native services initialization results:', results)
|
||||
|
||||
this.isInitialized = true
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize native services:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start all services after successful authentication
|
||||
*/
|
||||
async startServices() {
|
||||
if (!this.isInitialized) {
|
||||
await this.initialize()
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔄 NATIVE SERVICES: Starting all services...')
|
||||
|
||||
// Start background location tracking
|
||||
try {
|
||||
if (this.isNative) {
|
||||
await this.services.location.startTracking()
|
||||
console.log('Background location tracking started')
|
||||
}
|
||||
} catch (locationError) {
|
||||
console.error('Failed to start location tracking:', locationError)
|
||||
// Continue with other services
|
||||
}
|
||||
|
||||
// Perform initial security check
|
||||
try {
|
||||
if (this.services.antiSpoofing.shouldPerformSecurityCheck()) {
|
||||
console.log('Performing initial security check...')
|
||||
await this.services.antiSpoofing.performSecurityCheck()
|
||||
}
|
||||
} catch (securityError) {
|
||||
console.error('Failed to perform security check:', securityError)
|
||||
// Continue with other services
|
||||
}
|
||||
|
||||
// Register device if not already registered
|
||||
try {
|
||||
// Add a small delay to ensure auth data is fully stored
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
if (!(await this.services.deviceUuid.isDeviceRegistered())) {
|
||||
console.log('Registering device with server...')
|
||||
await this.services.deviceUuid.registerDeviceWithServer()
|
||||
}
|
||||
} catch (deviceError) {
|
||||
console.error('Failed to register device:', deviceError)
|
||||
// Continue - don't block login for device registration issues
|
||||
}
|
||||
|
||||
// Start periodic tasks
|
||||
try {
|
||||
this.startPeriodicTasks()
|
||||
} catch (periodicError) {
|
||||
console.error('Failed to start periodic tasks:', periodicError)
|
||||
// Continue
|
||||
}
|
||||
|
||||
console.log('✅ NATIVE SERVICES: Initialization completed (some services may have failed)')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('❌ NATIVE SERVICES: Critical error in startup:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all services
|
||||
*/
|
||||
async stopServices() {
|
||||
try {
|
||||
console.log('Stopping native services...')
|
||||
|
||||
// Stop background location tracking
|
||||
if (this.services.location.isLocationTrackingActive()) {
|
||||
await this.services.location.stopTracking()
|
||||
}
|
||||
|
||||
// Stop periodic tasks
|
||||
this.stopPeriodicTasks()
|
||||
|
||||
console.log('All native services stopped')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to stop native services:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic background tasks
|
||||
*/
|
||||
startPeriodicTasks() {
|
||||
// Security check every 24 hours
|
||||
this.securityCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
if (this.services.antiSpoofing.shouldPerformSecurityCheck()) {
|
||||
await this.services.antiSpoofing.performSecurityCheck()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Periodic security check failed:', error)
|
||||
}
|
||||
}, 24 * 60 * 60 * 1000) // 24 hours
|
||||
|
||||
// Device heartbeat every 15 minutes
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
try {
|
||||
await this.services.deviceUuid.sendDeviceHeartbeat()
|
||||
} catch (error) {
|
||||
console.error('Device heartbeat failed:', error)
|
||||
}
|
||||
}, 15 * 60 * 1000) // 15 minutes
|
||||
|
||||
// Retry failed operations every 5 minutes
|
||||
this.retryInterval = setInterval(async () => {
|
||||
try {
|
||||
await this.services.location.retryPendingLocationUpdates()
|
||||
await this.services.antiSpoofing.retryPendingSecurityChecks()
|
||||
} catch (error) {
|
||||
console.error('Retry operations failed:', error)
|
||||
}
|
||||
}, 5 * 60 * 1000) // 5 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic background tasks
|
||||
*/
|
||||
stopPeriodicTasks() {
|
||||
if (this.securityCheckInterval) {
|
||||
clearInterval(this.securityCheckInterval)
|
||||
this.securityCheckInterval = null
|
||||
}
|
||||
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
|
||||
if (this.retryInterval) {
|
||||
clearInterval(this.retryInterval)
|
||||
this.retryInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app going to background
|
||||
*/
|
||||
async onAppBackground() {
|
||||
console.log('App going to background - ensuring services continue')
|
||||
|
||||
try {
|
||||
// Ensure location tracking continues in background
|
||||
if (this.isNative && !this.services.location.isLocationTrackingActive()) {
|
||||
await this.services.location.startTracking()
|
||||
}
|
||||
|
||||
// Send immediate heartbeat
|
||||
await this.services.deviceUuid.sendDeviceHeartbeat()
|
||||
} catch (error) {
|
||||
console.error('Failed to handle app background:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle app coming to foreground
|
||||
*/
|
||||
async onAppForeground() {
|
||||
console.log('App coming to foreground - checking service status')
|
||||
|
||||
try {
|
||||
// Check if services are still running
|
||||
if (this.isNative && !this.services.location.isLocationTrackingActive()) {
|
||||
console.log('Location tracking stopped, restarting...')
|
||||
await this.services.location.startTracking()
|
||||
}
|
||||
|
||||
// Retry any pending operations
|
||||
await this.services.location.retryPendingLocationUpdates()
|
||||
await this.services.antiSpoofing.retryPendingSecurityChecks()
|
||||
|
||||
// Send heartbeat
|
||||
await this.services.deviceUuid.sendDeviceHeartbeat()
|
||||
} catch (error) {
|
||||
console.error('Failed to handle app foreground:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user login
|
||||
*/
|
||||
async onUserLogin() {
|
||||
console.log('🔄 NATIVE SERVICES: User logged in - starting services')
|
||||
const result = await this.startServices()
|
||||
console.log('🔄 NATIVE SERVICES: Start services result:', result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user logout
|
||||
*/
|
||||
async onUserLogout() {
|
||||
console.log('User logged out - stopping native services')
|
||||
return await this.stopServices()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service status
|
||||
*/
|
||||
getServiceStatus() {
|
||||
return {
|
||||
isInitialized: this.isInitialized,
|
||||
isNative: this.isNative,
|
||||
services: {
|
||||
location: {
|
||||
initialized: this.services.location.isInitialized,
|
||||
tracking: this.services.location.isLocationTrackingActive(),
|
||||
lastUpdate: this.services.location.getLastLocationUpdateTime()
|
||||
},
|
||||
antiSpoofing: {
|
||||
initialized: true,
|
||||
lastCheck: this.services.antiSpoofing.getLastSecurityCheckTime()
|
||||
},
|
||||
deviceUuid: {
|
||||
initialized: true,
|
||||
uuid: this.services.deviceUuid.cachedDeviceUuid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force security check
|
||||
*/
|
||||
async forceSecurityCheck() {
|
||||
return await this.services.antiSpoofing.forceSecurityCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current location
|
||||
*/
|
||||
async getCurrentLocation() {
|
||||
return await this.services.location.getCurrentLocationAndSend()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate device
|
||||
*/
|
||||
async validateDevice() {
|
||||
return await this.services.deviceUuid.validateDeviceWithServer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on native platform
|
||||
*/
|
||||
isNativePlatform() {
|
||||
return this.isNative
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device UUID
|
||||
*/
|
||||
async getDeviceUuid() {
|
||||
return await this.services.deviceUuid.getDeviceUuid()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all services (for testing/troubleshooting)
|
||||
*/
|
||||
async resetServices() {
|
||||
try {
|
||||
await this.stopServices()
|
||||
|
||||
// Reset device UUID
|
||||
await this.services.deviceUuid.resetDeviceUuid()
|
||||
|
||||
// Clear stored credentials
|
||||
await this.services.auth.clearStoredCredentials()
|
||||
|
||||
this.isInitialized = false
|
||||
this.initializationPromise = null
|
||||
|
||||
console.log('All services reset successfully')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to reset services:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const nativeServicesManager = new NativeServicesManager()
|
||||
export default nativeServicesManager
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6">
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('changePasswordTitle') }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-8">
|
||||
<div class="bg-white rounded-2xl shadow-lg p-8 w-full max-w-lg mx-auto">
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-6">
|
||||
<!-- Success Message -->
|
||||
<div v-if="successMessage" class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg">
|
||||
{{ $t(successMessage) }}
|
||||
</div>
|
||||
<!-- Error Message -->
|
||||
<div v-if="errorMessage" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-lg">
|
||||
{{ $t(errorMessage) }}
|
||||
</div>
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div>
|
||||
<label for="currentPassword" class="block text-sm font-medium text-gray-700 mb-2">{{ $t('currentPassword') }}</label>
|
||||
<input type="password" id="currentPassword" v-model="passwords.currentPassword" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="newPassword" class="block text-sm font-medium text-gray-700 mb-2">{{ $t('newPassword') }}</label>
|
||||
<input type="password" id="newPassword" v-model="passwords.newPassword" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-2">{{ $t('confirmNewPassword') }}</label>
|
||||
<input type="password" id="confirmPassword" v-model="passwords.confirmPassword" required
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" :disabled="loading"
|
||||
class="w-full py-3 text-lg font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-transform transform hover:scale-105 disabled:opacity-60 disabled:cursor-not-allowed shadow-md">
|
||||
{{ loading ? $t('updating') : $t('updatePassword') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const passwords = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
if (passwords.value.newPassword !== passwords.value.confirmPassword) {
|
||||
errorMessage.value = 'passwordsNoMatch'
|
||||
return
|
||||
}
|
||||
if (passwords.value.newPassword.length < 6) {
|
||||
errorMessage.value = 'passwordTooShort'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await apiFetch('/api/worker/change-password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
currentPassword: passwords.value.currentPassword,
|
||||
newPassword: passwords.value.newPassword,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
if (response.status === 401) {
|
||||
errorMessage.value = 'invalidCurrentPassword'
|
||||
} else {
|
||||
errorMessage.value = errorData.message || 'passwordUpdateError'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
successMessage.value = 'passwordUpdated'
|
||||
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
|
||||
} catch (err) {
|
||||
errorMessage.value = err.message || 'passwordUpdateError'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="flex justify-center items-center min-h-screen bg-gray-100">
|
||||
<div class="w-full max-w-sm p-8 space-y-6 bg-white rounded-2xl shadow-lg">
|
||||
<!-- App Logo -->
|
||||
<div class="flex justify-center">
|
||||
<ArrowRightOnRectangleIcon class="w-16 h-16 text-blue-600" />
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="text-3xl font-extrabold text-center text-gray-900">
|
||||
{{ t('login') }}
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label for="username" class="sr-only">{{ t('username') }}</label>
|
||||
<input type="text" id="username" v-model="username" autocomplete="username"
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
:placeholder="t('username')"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="sr-only">{{ t('password') }}</label>
|
||||
<input type="password" id="password" v-model="password" autocomplete="current-password"
|
||||
class="w-full px-4 py-3 text-gray-700 bg-gray-50 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
:placeholder="t('password')"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<!-- Remember Me Checkbox -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="rememberMe" v-model="rememberMe"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" />
|
||||
<label for="rememberMe" class="ml-2 block text-sm text-gray-900">
|
||||
{{ t('rememberMe') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit"
|
||||
class="w-full py-3 text-lg font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-transform transform hover:scale-105 disabled:opacity-60 disabled:cursor-not-allowed shadow-md"
|
||||
:disabled="loading">
|
||||
{{ loading ? t('loggingIn') : t('login') }}
|
||||
</button>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="error" class="text-sm text-center text-red-600 mt-4">
|
||||
{{ t(error) !== error ? t(error) : error }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ArrowRightOnRectangleIcon } from '@heroicons/vue/24/outline'
|
||||
import { authService } from '@/services/authService.js'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const rememberMe = ref(false)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const loginResult = await authService.login(
|
||||
username.value,
|
||||
password.value,
|
||||
rememberMe.value
|
||||
)
|
||||
|
||||
if (loginResult.success) {
|
||||
console.log('✅ LOGIN SUCCESS:', loginResult)
|
||||
|
||||
sessionStorage.setItem('userId', loginResult.userId.toString())
|
||||
sessionStorage.setItem('userRole', loginResult.userRole)
|
||||
sessionStorage.setItem('token', loginResult.token)
|
||||
|
||||
console.log('✅ SESSION STORAGE SET:', {
|
||||
userId: sessionStorage.getItem('userId'),
|
||||
userRole: sessionStorage.getItem('userRole')
|
||||
})
|
||||
|
||||
if (loginResult.userRole === 'worker') {
|
||||
console.log('🔄 WORKER LOGIN - Starting native services...')
|
||||
|
||||
try {
|
||||
await nativeServicesManager.onUserLogin()
|
||||
console.log('✅ NATIVE SERVICES STARTED')
|
||||
} catch (serviceError) {
|
||||
console.error('❌ NATIVE SERVICES FAILED:', serviceError)
|
||||
}
|
||||
|
||||
console.log('🔄 NAVIGATING TO WORKER DASHBOARD...')
|
||||
try {
|
||||
await router.push('/worker/dashboard')
|
||||
console.log('✅ NAVIGATION COMPLETED')
|
||||
} catch (navError) {
|
||||
console.error('❌ NAVIGATION FAILED:', navError)
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ NON-WORKER LOGIN - Worker client app only')
|
||||
error.value = 'This application is designed for workers only.'
|
||||
await authService.logout()
|
||||
}
|
||||
} else {
|
||||
if (loginResult.error.includes('Device not authorized')) {
|
||||
error.value = 'deviceNotAuthorized'
|
||||
} else if (loginResult.error.includes('Invalid credentials')) {
|
||||
error.value = 'invalidCredentials'
|
||||
} else if (loginResult.error.includes('Network error')) {
|
||||
error.value = 'failedConnection'
|
||||
} else {
|
||||
error.value = loginResult.error
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
error.value = 'failedConnection'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* All styles are now handled by Tailwind CSS classes in the template. */
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6">
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('personal') }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-8 space-y-6">
|
||||
<!-- Menu Items -->
|
||||
<div class="bg-white rounded-2xl shadow-lg overflow-hidden">
|
||||
<!-- Clock History -->
|
||||
<router-link to="/worker/history" class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<ChartBarIcon class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg text-gray-900">{{ $t('clockHistory') }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ $t('viewMyClockHistory') }}</p>
|
||||
</div>
|
||||
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
|
||||
</router-link>
|
||||
|
||||
<!-- Service Status -->
|
||||
<div class="p-5 border-b border-gray-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<CogIcon class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg text-gray-900">{{ $t('servicesStatus') }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ $t('systemServicesStatus') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<NativeServicesStatus />
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<router-link to="/worker/change-password" class="flex items-center p-5 border-b border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<LockClosedIcon class="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg text-gray-900">{{ $t('changePassword') }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ $t('updateYourPassword') }}</p>
|
||||
</div>
|
||||
<ChevronRightIcon class="w-6 h-6 text-gray-400" />
|
||||
</router-link>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div class="flex items-center p-5">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<LanguageIcon class="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-lg text-gray-900 mb-2">{{ $t('language') }}</h3>
|
||||
<select v-model="currentLang" @change="changeLang" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="en">{{ $t('english') }}</option>
|
||||
<option value="ms">{{ $t('malay') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button @click="logout" class="w-full flex items-center justify-center p-5 bg-white rounded-2xl shadow-lg hover:bg-red-50 transition-colors">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center mr-5">
|
||||
<ArrowLeftOnRectangleIcon class="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<div class="flex-grow text-left">
|
||||
<h3 class="font-semibold text-lg text-red-600">{{ $t('logout') }}</h3>
|
||||
<p class="text-sm text-red-500">{{ $t('signOutOfAccount') }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChartBarIcon, CogIcon, LockClosedIcon, LanguageIcon, ArrowLeftOnRectangleIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
|
||||
import { authService } from '@/services/authService.js'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
import NativeServicesStatus from '@/components/NativeServicesStatus.vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const currentLang = ref(locale.value)
|
||||
|
||||
const changeLang = () => {
|
||||
locale.value = currentLang.value
|
||||
localStorage.setItem('lang', currentLang.value)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await authService.logout()
|
||||
await nativeServicesManager.onUserLogout()
|
||||
sessionStorage.removeItem('userId')
|
||||
sessionStorage.removeItem('userRole')
|
||||
sessionStorage.removeItem('token')
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
sessionStorage.clear()
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedLang = localStorage.getItem('lang')
|
||||
if (savedLang) {
|
||||
currentLang.value = savedLang
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<!-- Header -->
|
||||
<header class="bg-blue-600 text-white shadow-lg">
|
||||
<div class="px-4 py-6">
|
||||
<h1 class="text-3xl font-bold text-center">{{ $t('appTitle') }}</h1>
|
||||
<p class="text-center text-blue-200 mt-1">{{ workerName }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-8 space-y-8">
|
||||
<!-- Clock Status Card -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6 text-center">
|
||||
<div class="flex items-center justify-center gap-4 mb-4">
|
||||
<component :is="isClockedIn ? CheckCircleIcon : ClockIcon"
|
||||
:class="['w-16 h-16', isClockedIn ? 'text-green-500' : 'text-red-500']" />
|
||||
</div>
|
||||
<p class="text-lg text-gray-600 mb-1">{{ $t('yourStatus') }}</p>
|
||||
<h2 class="text-3xl font-bold" :class="isClockedIn ? 'text-green-600' : 'text-red-600'">
|
||||
{{ clockStatus }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-if="successMessage" class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg shadow-md">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
<div v-if="errorMessage" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-lg shadow-md">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- QR Scanner Card -->
|
||||
<div class="bg-white rounded-2xl shadow-lg p-6">
|
||||
<div v-if="!isScannerActive" class="space-y-4 text-center">
|
||||
<h3 class="text-xl font-semibold text-gray-800 mb-4">
|
||||
{{ $t('scanToClock', { action: $t(isClockedIn ? 'out' : 'in') }) }}
|
||||
</h3>
|
||||
<button @click="startScanner"
|
||||
class="w-full py-4 text-xl flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-transform transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-lg">
|
||||
<CameraIcon class="w-8 h-8" />
|
||||
<span>{{ $t('openCamera') }}</span>
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept="image/*" @change="handleFileUpload" hidden />
|
||||
</div>
|
||||
|
||||
<!-- QR Scanner Overlay -->
|
||||
<div id="qr-reader-container" v-show="isScannerActive"
|
||||
class="fixed inset-0 bg-black bg-opacity-90 flex flex-col items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-2xl p-6 w-full max-w-md">
|
||||
<h3 class="text-2xl font-bold text-gray-900 text-center mb-4">{{ $t('scanQRCode') }}</h3>
|
||||
<div id="qr-reader" class="w-full rounded-lg overflow-hidden border-4 border-gray-300"></div>
|
||||
</div>
|
||||
<button @click="stopScanner"
|
||||
class="mt-8 bg-red-600 hover:bg-red-700 text-white font-bold px-10 py-4 rounded-xl transition-transform transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 shadow-lg">
|
||||
{{ $t('cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CheckCircleIcon, ClockIcon, CameraIcon } from '@heroicons/vue/24/outline'
|
||||
import { Html5Qrcode } from 'html5-qrcode'
|
||||
import { apiFetch } from '@/api.js'
|
||||
import { authService } from '@/services/authService.js'
|
||||
import { nativeServicesManager } from '@/services/nativeServicesManager.js'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
let html5QrCode = null
|
||||
const fileInput = ref(null)
|
||||
const router = useRouter()
|
||||
|
||||
const isClockedIn = ref(false)
|
||||
const isScannerActive = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const workerName = ref('')
|
||||
|
||||
let userId = sessionStorage.getItem('userId')
|
||||
|
||||
const clockStatus = computed(() => (isClockedIn.value ? t('clockedIn') : t('clockedOut')))
|
||||
|
||||
const fetchWorkerDetails = async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/workers/${userId}`)
|
||||
if (data) {
|
||||
workerName.value = data.full_name
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = t('couldNotLoadWorkerInfo') + `: ${err.message}`
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCurrentStatus = async () => {
|
||||
try {
|
||||
const lastEvent = await apiFetch(`/api/worker/status/${userId}`)
|
||||
if (lastEvent) {
|
||||
isClockedIn.value = lastEvent.eventType === 'clock_in'
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = t('couldNotVerifyStatus') + `: ${err.message}`
|
||||
}
|
||||
}
|
||||
|
||||
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
|
||||
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
|
||||
try {
|
||||
const data = await apiFetch('/api/clock', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: userId,
|
||||
eventType,
|
||||
qrCodeValue,
|
||||
latitude,
|
||||
longitude,
|
||||
}),
|
||||
})
|
||||
|
||||
isClockedIn.value = !isClockedIn.value
|
||||
successMessage.value = t('successfullyClocked', { action: t(eventType) }) + ` ${data.location || t('site')}.`
|
||||
} catch (err) {
|
||||
errorMessage.value = t('errorOccurred') + `: ${err.message}`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userId) {
|
||||
userId = await authService.getUserId()
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const serviceStatus = nativeServicesManager.getServiceStatus()
|
||||
if (!serviceStatus.isInitialized) {
|
||||
await nativeServicesManager.initialize()
|
||||
}
|
||||
|
||||
const isAuth = await authService.isAuthenticated()
|
||||
if (isAuth && !serviceStatus.services.location.tracking) {
|
||||
await nativeServicesManager.startServices()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize native services:', error)
|
||||
}
|
||||
|
||||
fetchWorkerDetails()
|
||||
fetchCurrentStatus()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (html5QrCode && html5QrCode.isScanning) {
|
||||
stopScanner()
|
||||
}
|
||||
})
|
||||
|
||||
const clearMessages = () => {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
}
|
||||
|
||||
const startScanner = () => {
|
||||
isScannerActive.value = true
|
||||
clearMessages()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
html5QrCode = new Html5Qrcode('qr-reader')
|
||||
const config = { fps: 10, qrbox: { width: 250, height: 250 } }
|
||||
html5QrCode.start({ facingMode: 'environment' }, config, onScanSuccess, onScanFailure)
|
||||
} catch {
|
||||
errorMessage.value = t('unableToStartCamera')
|
||||
isScannerActive.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const stopScanner = () => {
|
||||
if (html5QrCode && html5QrCode.isScanning) {
|
||||
html5QrCode.stop().catch((err) => console.error('Failed to stop scanner cleanly.', err))
|
||||
}
|
||||
isScannerActive.value = false
|
||||
}
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
clearMessages()
|
||||
if (!html5QrCode) {
|
||||
html5QrCode = new Html5Qrcode('qr-reader', false)
|
||||
}
|
||||
html5QrCode
|
||||
.scanFile(file, true)
|
||||
.then(onScanSuccess)
|
||||
.catch(() => {
|
||||
onScanFailure(t('tryAgain'))
|
||||
})
|
||||
}
|
||||
|
||||
const onScanSuccess = async (decodedText) => {
|
||||
successMessage.value = t('qrDetectedGettingLocation')
|
||||
stopScanner()
|
||||
|
||||
try {
|
||||
const position = await Geolocation.getCurrentPosition({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 3600000
|
||||
})
|
||||
|
||||
await sendClockEvent(decodedText, position.coords.latitude, position.coords.longitude)
|
||||
} catch (geoError) {
|
||||
console.error('Geolocation error:', geoError)
|
||||
errorMessage.value = t('unableToRetrieveLocation', { message: geoError.message })
|
||||
}
|
||||
}
|
||||
|
||||
const onScanFailure = () => {
|
||||
errorMessage.value = t('qrNotDetectedTryAgain')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* All styles are now handled by Tailwind CSS classes in the template. */
|
||||
</style>
|
||||
|
||||