Initial commit

This commit is contained in:
sudomarcma
2025-07-07 14:18:30 +08:00
commit 8428d03051
104 changed files with 16528 additions and 0 deletions
+9
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
android/app/build
android/app/src/main/assets
dist
node_modules
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+31
View File
@@ -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
+31
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}
+23
View File
@@ -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"]
+59
View File
@@ -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
```
+101
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+55
View File
@@ -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")
}
+25
View File
@@ -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()
}
+21
View File
@@ -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());
}
}
+107
View File
@@ -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 {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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>
Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

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);
}
}
+29
View File
@@ -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
}
+24
View File
@@ -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')
+22
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
+252
View File
@@ -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" "$@"
+94
View File
@@ -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
+5
View File
@@ -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'
+16
View File
@@ -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'
}
+26
View File
@@ -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-----
+28
View File
@@ -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-----
+1209
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
]
}
}
}
+23
View File
@@ -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
+27
View File
@@ -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,
])
+16
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+9946
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+96
View File
@@ -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>
+37
View File
@@ -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()
}
+1
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+51
View File
@@ -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>
+246
View File
@@ -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>
+7
View File
@@ -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>
+7
View File
@@ -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>
+7
View File
@@ -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>
+19
View File
@@ -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>
+20
View File
@@ -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");
+249
View File
@@ -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"
}
+243
View File
@@ -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"
}
+13
View File
@@ -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')
+82
View File
@@ -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
+455
View File
@@ -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
+402
View File
@@ -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
+362
View File
@@ -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
+445
View File
@@ -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
+359
View File
@@ -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
+107
View File
@@ -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>
+144
View File
@@ -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>
+120
View File
@@ -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>
+235
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More