diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 650f3bb9..fe6a3a09 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,15 @@ updates: update-types: ["version-update:semver-major"] versioning-strategy: increase target-branch: dev + - package-ecosystem: "npm" + directory: "/client-vue3" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + versioning-strategy: increase + target-branch: feature/v3-migration - package-ecosystem: "pip" directory: "/server" schedule: diff --git a/.github/labeler.yml b/.github/labeler.yml index 335a3913..d232d154 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -30,6 +30,9 @@ labels: - label: "client" files: - "client/.*" +- label: "client-new" + files: + - "client-vue3/.*" - label: "server" files: - "server/.*" \ No newline at end of file diff --git a/client-vue3/.eslintrc.cjs b/client-vue3/.eslintrc.cjs index fd360529..e812c477 100644 --- a/client-vue3/.eslintrc.cjs +++ b/client-vue3/.eslintrc.cjs @@ -4,6 +4,12 @@ module.exports = { node: true, browser: true, }, + globals: { + RequestInfo: 'readonly', + RequestInit: 'readonly', + Headers: 'readonly', + fetch: 'readonly', + }, extends: [ 'eslint:recommended', '@vue/eslint-config-typescript/recommended', @@ -35,6 +41,10 @@ module.exports = { }, settings: { 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + }, node: { extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'], }, diff --git a/client-vue3/env.d.ts b/client-vue3/env.d.ts index 80a7467e..6c024075 100644 --- a/client-vue3/env.d.ts +++ b/client-vue3/env.d.ts @@ -1,4 +1,5 @@ /// +/// declare module '*.vue' { import type { DefineComponent } from 'vue' diff --git a/client-vue3/package-lock.json b/client-vue3/package-lock.json index d745cb5b..dac7d3be 100644 --- a/client-vue3/package-lock.json +++ b/client-vue3/package-lock.json @@ -19,6 +19,7 @@ "@vue/tsconfig": "^0.5.0", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", + "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-vue": "^9.27.0", "typescript": "^5.4.0", @@ -76,6 +77,40 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -622,6 +657,19 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -947,6 +995,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1171,6 +1230,275 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -2271,6 +2599,31 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -2293,6 +2646,41 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", + "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, "node_modules/eslint-module-utils": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", @@ -2620,6 +3008,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2821,6 +3227,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3200,6 +3619,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -3688,6 +4117,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -3746,6 +4188,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4031,13 +4489,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -4233,6 +4691,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4560,6 +5028,16 @@ "node": ">=0.10.0" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -4702,6 +5180,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4741,6 +5236,14 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4885,6 +5388,41 @@ "dev": true, "license": "MIT" }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/client-vue3/package.json b/client-vue3/package.json index d28c6c4e..9b7ffe63 100644 --- a/client-vue3/package.json +++ b/client-vue3/package.json @@ -16,9 +16,9 @@ "node": ">=22.0.0 <23" }, "dependencies": { + "pinia": "^2.1.0", "vue": "^3.4.0", - "vue-router": "^4.3.0", - "pinia": "^2.1.0" + "vue-router": "^4.3.0" }, "devDependencies": { "@types/node": "^20.14.0", @@ -27,6 +27,7 @@ "@vue/tsconfig": "^0.5.0", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", + "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-vue": "^9.27.0", "typescript": "^5.4.0", @@ -38,4 +39,4 @@ "last 2 versions", "not dead" ] -} \ No newline at end of file +} diff --git a/client-vue3/src/composables/useWebSocket.ts b/client-vue3/src/composables/useWebSocket.ts new file mode 100644 index 00000000..b41cb4ed --- /dev/null +++ b/client-vue3/src/composables/useWebSocket.ts @@ -0,0 +1,353 @@ +import { + ref, onMounted, onUnmounted, watch, computed, +} from 'vue'; +import { useWebSocketStore, type WebSocketMessage } from '../stores/websocket'; +import { useAuthStore } from '../stores/auth'; + +export interface WebSocketOptions { + reconnection?: boolean; + reconnectionAttempts?: number; + reconnectionDelay?: number; + maxReconnectionDelay?: number; + reconnectionDelayGrowFactor?: number; +} + +export function useWebSocket(url?: string, options: WebSocketOptions = {}) { + const websocketStore = useWebSocketStore(); + const authStore = useAuthStore(); + + const socket = ref(null); + const reconnectAttempts = ref(0); + const reconnectTimer = ref(null); + const isReconnecting = ref(false); + + const defaultOptions: WebSocketOptions = { + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 1.3, + }; + + const config = { ...defaultOptions, ...options }; + + // Create WebSocket URL - matches Vue 2 pattern exactly + const getWebSocketUrl = (): string => { + if (url) return url; + + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const { hostname } = window.location; + const { port } = window.location; + return `${protocol}://${hostname}:${port}/api/v1/ws`; + }; + + // Send message to WebSocket + const sendMessage = (message: WebSocketMessage) => { + if (socket.value && socket.value.readyState === WebSocket.OPEN) { + socket.value.send(JSON.stringify(message)); + } else { + console.error('WebSocket is not connected'); + } + }; + + // Send object message (matches Vue 2 sendObj method) + const sendObj = (obj: WebSocketMessage) => { + sendMessage(obj); + }; + + // Handle specific OP codes - matches Vue 2 websocket.js switch statement + const handleSpecificOpCodes = (message: WebSocketMessage) => { + switch (message.OP) { + case 'SET_UUID': + if (websocketStore.internalUUID != null) { + console.log('Reconnecting to WebSocket with existing UUID:', websocketStore.internalUUID); + sendObj({ + OP: 'REFRESH_CLIENT', + DATA: websocketStore.internalUUID, + }); + } else { + console.log('Connecting to WebSocket as new client, with UUID:', message.DATA); + websocketStore.setInternalUUID(message.DATA as string); + websocketStore.setNewConnection(true); + } + if (!websocketStore.authenticated && !websocketStore.authenticationInProgress) { + websocketStore.setPendingAuthentication(true); + } + break; + + case 'WS_AUTH_SUCCESS': + websocketStore.setAuthenticated(true); + websocketStore.setAuthenticationInProgress(false); + websocketStore.setPendingAuthentication(false); + websocketStore.setAuthSucceeded(true); + console.log('WebSocket authenticated successfully'); + break; + + case 'WS_AUTH_ERROR': + websocketStore.setAuthenticated(false); + websocketStore.setAuthenticationInProgress(false); + websocketStore.setPendingAuthentication(false); + console.error(`WebSocket authentication error: ${message.DATA}`); + break; + + case 'WS_TOKEN_REFRESH_SUCCESS': + console.log('WebSocket token refreshed successfully'); + break; + + case 'START_SHOW': + // TODO: Navigate to /live when router is integrated + console.log('Show started - should navigate to /live'); + break; + + case 'STOP_SHOW': + // TODO: Navigate to / when router is integrated + console.log('Show stopped - should navigate to /'); + break; + + case 'RELOAD_CLIENT': + window.location.reload(); + break; + + case 'NOOP': + // Do nothing for NOOP operations + break; + + default: + console.error(`Unknown OP received from WebSocket: ${message.OP}`); + } + }; + + // Handle incoming WebSocket messages - CRITICAL: matches Vue 2 logic exactly + const handleMessage = (event: MessageEvent) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + + // Store the message in the WebSocket store + websocketStore.setMessage(message); + + // Handle messages with OP key - this is the core logic from Vue 2 + if (message.OP) { + // Handle specific OP codes that have special behavior + handleSpecificOpCodes(message); + + // If the message contains an ACTION key, dispatch it + if (message.ACTION && message.ACTION !== 'NOOP') { + // TODO: Implement action dispatching to appropriate stores + // This will need to be implemented when we have all stores migrated + console.log('Action to dispatch:', message.ACTION, message); + + // For now, handle some critical actions manually + if (message.ACTION === 'WS_SETTINGS_CHANGED') { + // TODO: Update settings - will be implemented when settings store is ready + console.log('Settings changed:', message.DATA); + } + } + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + // Forward declarations to solve hoisting issues + let connect: () => void; + let attemptReconnect: () => void; + + // WebSocket connection handlers - defined first to avoid hoisting issues + const handleOpen = () => { + console.log('WebSocket connected'); + websocketStore.handleSocketOpen(); + reconnectAttempts.value = 0; + isReconnecting.value = false; + + // Add socket reference for sendObj compatibility + if (socket.value) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (socket.value as any).sendObj = sendObj; + } + }; + + const handleClose = () => { + console.log('WebSocket disconnected'); + websocketStore.handleSocketClose(); + + if (config.reconnection && !isReconnecting.value) { + attemptReconnect(); + } + }; + + const handleError = (event: Event) => { + console.error('WebSocket error:', event); + websocketStore.handleSocketError(); + }; + + // Reconnection logic + attemptReconnect = () => { + if (reconnectAttempts.value >= (config.reconnectionAttempts || 5)) { + websocketStore.handleSocketReconnectError(); + return; + } + + isReconnecting.value = true; + reconnectAttempts.value += 1; + + websocketStore.handleSocketReconnect(reconnectAttempts.value); + + const delay = Math.min( + (config.reconnectionDelay || 1000) + * (config.reconnectionDelayGrowFactor || 1.3) ** (reconnectAttempts.value - 1), + config.maxReconnectionDelay || 5000, + ); + + reconnectTimer.value = window.setTimeout(() => { + connect(); + }, delay); + }; + + // Declare connect function + connect = () => { + try { + socket.value = new WebSocket(getWebSocketUrl()); + + socket.value.addEventListener('open', handleOpen); + socket.value.addEventListener('close', handleClose); + socket.value.addEventListener('error', handleError); + socket.value.addEventListener('message', handleMessage); + + // Add sendObj method for compatibility + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (socket.value as any).sendObj = sendObj; + } catch (error) { + console.error('Error creating WebSocket connection:', error); + } + }; + + // Disconnect from WebSocket + const disconnect = () => { + if (reconnectTimer.value) { + window.clearTimeout(reconnectTimer.value); + reconnectTimer.value = null; + } + + if (socket.value) { + socket.value.removeEventListener('open', handleOpen); + socket.value.removeEventListener('close', handleClose); + socket.value.removeEventListener('error', handleError); + socket.value.removeEventListener('message', handleMessage); + socket.value.close(); + socket.value = null; + } + + websocketStore.setConnected(false); + isReconnecting.value = false; + reconnectAttempts.value = 0; + }; + + // Handle WebSocket authentication success + const handleWsAuthSuccess = async () => { + // After successful authentication, register as a new client if this is a new connection + if (websocketStore.newConnection) { + if (socket.value && socket.value.readyState === WebSocket.OPEN) { + sendObj({ + OP: 'NEW_CLIENT', + DATA: {}, + }); + websocketStore.clearNewConnection(); + } + } + }; + + // WebSocket authentication + const authenticateWebSocket = async () => { + if (!authStore.authToken || !socket.value || socket.value.readyState !== WebSocket.OPEN) { + return; + } + + websocketStore.setAuthenticationInProgress(true); + websocketStore.clearPendingAuthentication(); + + sendObj({ + OP: 'AUTHENTICATE', + DATA: { + token: authStore.authToken, + }, + }); + + console.log('Sent WebSocket authentication request'); + }; + + // Check WebSocket state and handle pending operations + const checkWebSocketState = async () => { + if (websocketStore.pendingAuthentication && authStore.authToken) { + await authenticateWebSocket(); + } + + if (websocketStore.authSucceeded) { + await handleWsAuthSuccess(); + websocketStore.clearAuthSucceeded(); + } + + if (websocketStore.newConnection + && !websocketStore.pendingAuthentication + && !websocketStore.authenticationInProgress + && !websocketStore.authSucceeded) { + if (socket.value && socket.value.readyState === WebSocket.OPEN) { + sendObj({ + OP: 'NEW_CLIENT', + DATA: {}, + }); + websocketStore.clearNewConnection(); + } + } + }; + + const refreshWebSocketToken = async () => { + if (!authStore.authToken || !socket.value || socket.value.readyState !== WebSocket.OPEN) { + return; + } + + sendObj({ + OP: 'REFRESH_TOKEN', + DATA: { + token: authStore.authToken, + }, + }); + + console.log('Sent WebSocket token refresh'); + }; + + // Watch for auth token changes to trigger WebSocket authentication + watch(() => authStore.authToken, (newToken) => { + if (newToken && websocketStore.isConnected && !websocketStore.authenticated) { + authenticateWebSocket(); + } + }); + + // Periodic check for WebSocket state + let stateCheckInterval: number | null = null; + + onMounted(() => { + connect(); + + // Set up periodic state checking (matches Vue 2 behavior) + stateCheckInterval = window.setInterval(checkWebSocketState, 1000); + }); + + onUnmounted(() => { + if (stateCheckInterval) { + window.clearInterval(stateCheckInterval); + } + disconnect(); + }); + + return { + socket, + isConnected: computed(() => websocketStore.isConnected), + isAuthenticated: computed(() => websocketStore.authenticated), + sendMessage, + sendObj, + connect, + disconnect, + authenticateWebSocket, + refreshWebSocketToken, + }; +} diff --git a/client-vue3/src/main.ts b/client-vue3/src/main.ts index 9f167694..8e28ea4e 100644 --- a/client-vue3/src/main.ts +++ b/client-vue3/src/main.ts @@ -2,14 +2,24 @@ import { createApp } from 'vue'; import { createPinia } from 'pinia'; import App from './App.vue'; import router from './router'; +import setupHttpInterceptor from './utils/httpInterceptor'; import './assets/styles/main.css'; +// Setup HTTP interceptor for automatic JWT token injection +setupHttpInterceptor(); + const app = createApp(App); // State management -app.use(createPinia()); +const pinia = createPinia(); +app.use(pinia); // Router app.use(router); app.mount('#app'); + +// Initialize WebSocket connection after app is mounted +// This matches the Vue 2 pattern where WebSocket is initialized at app level +// The connection will be established when the composable is first used in a component +console.log('Vue 3 DigiScript app initialized - WebSocket will connect on first use'); diff --git a/client-vue3/src/router/index.ts b/client-vue3/src/router/index.ts index 2cac29e5..cea1147e 100644 --- a/client-vue3/src/router/index.ts +++ b/client-vue3/src/router/index.ts @@ -15,6 +15,11 @@ const router = createRouter({ // Lazy-loaded route component: () => import('../views/AboutView.vue'), }, + { + path: '/websocket-test', + name: 'websocket-test', + component: () => import('../views/WebSocketTest.vue'), + }, ], }); diff --git a/client-vue3/src/stores/auth.ts b/client-vue3/src/stores/auth.ts new file mode 100644 index 00000000..d0f25160 --- /dev/null +++ b/client-vue3/src/stores/auth.ts @@ -0,0 +1,365 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; + +export interface User { + id: string; + username: string; + is_admin: boolean; + // Add other user properties as needed +} + +export interface RbacRole { + id: string; + name: string; + permissions: string[]; + // Add other RBAC properties as needed +} + +export interface UserSettings { + [key: string]: unknown; +} + +export interface StageDirectionStyleOverride { + id: string; + style: string; + // Add other style override properties as needed +} + +export const useAuthStore = defineStore('auth', () => { + // State with localStorage persistence for auth token + const currentUser = ref(null); + const currentRbac = ref(null); + const showUsers = ref([]); + const authToken = ref(localStorage.getItem('digiscript_auth_token')); + const tokenRefreshInterval = ref(null); + const userSettings = ref({}); + const stageDirectionStyleOverrides = ref([]); + + // Getters + const isAuthenticated = computed(() => !!authToken.value); + const isAdmin = computed(() => currentUser.value?.is_admin || false); + + // Actions + function setCurrentUser(user: User | null) { + currentUser.value = user; + } + + function setShowUsers(users: User[]) { + showUsers.value = users; + } + + function setCurrentRbac(rbac: RbacRole[] | null) { + currentRbac.value = rbac; + } + + function setAuthToken(token: string | null) { + authToken.value = token; + if (token) { + localStorage.setItem('digiscript_auth_token', token); + } else { + localStorage.removeItem('digiscript_auth_token'); + } + } + + function setTokenRefreshInterval(intervalId: number | null) { + if (tokenRefreshInterval.value) { + window.clearInterval(tokenRefreshInterval.value); + } + tokenRefreshInterval.value = intervalId; + } + + function setUserSettings(settings: UserSettings) { + userSettings.value = settings; + } + + function setStageDirectionStyleOverrides(overrides: StageDirectionStyleOverride[]) { + stageDirectionStyleOverrides.value = overrides; + } + + // Clear all auth data (for logout) + function clearAuthData() { + currentUser.value = null; + currentRbac.value = null; + showUsers.value = []; + setAuthToken(null); + if (tokenRefreshInterval.value) { + window.clearInterval(tokenRefreshInterval.value); + tokenRefreshInterval.value = null; + } + userSettings.value = {}; + stageDirectionStyleOverrides.value = []; + } + + // Utility function to create API URLs + function makeURL(path: string): string { + return `${window.location.protocol}//${window.location.hostname}:${window.location.port}${path}`; + } + + // API Actions + async function getUsers() { + if (!currentUser.value?.is_admin) { + return; + } + + try { + const response = await fetch(makeURL('/api/v1/auth/users')); + if (response.ok) { + const data = await response.json(); + setShowUsers(data.users); + } else { + console.error('Unable to get users'); + // TODO: Show error toast when toast functionality is added + } + } catch (error) { + console.error('Error fetching users:', error); + } + } + + async function createUser(user: { username: string; password: string; is_admin: boolean }) { + try { + const response = await fetch(makeURL('/api/v1/auth/create'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(user), + }); + + if (response.ok) { + await getUsers(); + // TODO: Show success toast + console.log('User created successfully'); + } else { + const responseBody = await response.json(); + console.error('Unable to create user:', responseBody.message); + // TODO: Show error toast + } + } catch (error) { + console.error('Error creating user:', error); + } + } + + async function deleteUser(userId: string) { + try { + const response = await fetch(makeURL('/api/v1/auth/delete'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: userId }), + }); + + if (response.ok) { + await getUsers(); + // TODO: Show success toast + console.log('User deleted successfully'); + } else { + const responseBody = await response.json(); + console.error('Unable to delete user:', responseBody.message); + // TODO: Show error toast + } + } catch (error) { + console.error('Error deleting user:', error); + } + } + + async function getCurrentUser() { + try { + const response = await fetch(makeURL('/api/v1/auth')); + if (response.ok) { + const user = await response.json(); + setCurrentUser(Object.keys(user).length === 0 ? null : user); + } else { + console.error('Unable to get current user'); + } + } catch (error) { + console.error('Error fetching current user:', error); + } + } + + async function getCurrentRbac() { + try { + const response = await fetch(makeURL('/api/v1/rbac/user/roles')); + if (response.ok) { + const rbac = await response.json(); + setCurrentRbac(rbac.roles); + } else { + console.error('Unable to get current user\'s RBAC roles'); + } + } catch (error) { + console.error('Error fetching RBAC roles:', error); + } + } + + async function refreshToken(): Promise { + if (!authToken.value) return false; + + try { + const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + if (response.ok) { + const data = await response.json(); + setAuthToken(data.access_token); + // TODO: Refresh WebSocket token when WebSocket composable is ready + console.log('Token refreshed successfully'); + return true; + } + console.error('Failed to refresh token'); + return false; + } catch (error) { + console.error('Error refreshing token:', error); + return false; + } + } + + function setupTokenRefresh() { + if (tokenRefreshInterval.value) { + clearInterval(tokenRefreshInterval.value); + } + + const refreshInterval = window.setInterval(async () => { + if (authToken.value) { + await refreshToken(); + } else { + window.clearInterval(refreshInterval); + setTokenRefreshInterval(null); + } + }, 1000 * 60 * 30); // 30 minutes + + setTokenRefreshInterval(refreshInterval); + } + + async function getUserSettings() { + try { + const response = await fetch(makeURL('/api/v1/user/settings')); + if (response.ok) { + const settings = await response.json(); + setUserSettings(settings); + } else { + console.error('Unable to fetch user settings'); + } + } catch (error) { + console.error('Error fetching user settings:', error); + } + } + + async function getStageDirectionStyleOverrides() { + try { + const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'), { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (response.ok) { + const respJson = await response.json(); + setStageDirectionStyleOverrides(respJson.overrides); + } else { + console.error('Unable to load stage direction style overrides'); + } + } catch (error) { + console.error('Error fetching stage direction overrides:', error); + } + } + + async function addStageDirectionStyleOverride(style: unknown) { + try { + const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(style), + }); + + if (response.ok) { + await getStageDirectionStyleOverrides(); + // TODO: Show success toast + console.log('Added new stage direction style override'); + } else { + console.error('Unable to add new stage direction style override'); + // TODO: Show error toast + } + } catch (error) { + console.error('Error adding stage direction override:', error); + } + } + + async function deleteStageDirectionStyleOverride(styleId: string) { + try { + const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'), { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: styleId }), + }); + + if (response.ok) { + await getStageDirectionStyleOverrides(); + // TODO: Show success toast + console.log('Deleted stage direction style override'); + } else { + console.error('Unable to delete stage direction style override'); + // TODO: Show error toast + } + } catch (error) { + console.error('Error deleting stage direction override:', error); + } + } + + async function updateStageDirectionStyleOverride(style: unknown) { + try { + const response = await fetch(makeURL('/api/v1/user/settings/stage_direction_overrides'), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(style), + }); + + if (response.ok) { + await getStageDirectionStyleOverrides(); + // TODO: Show success toast + console.log('Updated stage direction style override'); + } else { + console.error('Unable to edit stage direction style override'); + // TODO: Show error toast + } + } catch (error) { + console.error('Error updating stage direction override:', error); + } + } + + return { + // State + currentUser, + currentRbac, + showUsers, + authToken, + tokenRefreshInterval, + userSettings, + stageDirectionStyleOverrides, + + // Getters + isAuthenticated, + isAdmin, + + // Actions + setCurrentUser, + setShowUsers, + setCurrentRbac, + setAuthToken, + setTokenRefreshInterval, + setUserSettings, + setStageDirectionStyleOverrides, + clearAuthData, + + // API Actions + getUsers, + createUser, + deleteUser, + getCurrentUser, + getCurrentRbac, + refreshToken, + setupTokenRefresh, + getUserSettings, + getStageDirectionStyleOverrides, + addStageDirectionStyleOverride, + deleteStageDirectionStyleOverride, + updateStageDirectionStyleOverride, + }; +}); diff --git a/client-vue3/src/stores/index.ts b/client-vue3/src/stores/index.ts index 49c32626..1762ffec 100644 --- a/client-vue3/src/stores/index.ts +++ b/client-vue3/src/stores/index.ts @@ -1,19 +1,18 @@ import { createPinia } from 'pinia'; -// Create and export the Pinia store instance -const pinia = createPinia(); +// Import stores +export { useWebSocketStore } from './websocket'; +export { useAuthStore } from './auth'; // Export store types for future use export type RootState = { - // Future store state types will be added here during Phase 2 - // This will include user state, websocket state, show state, etc. + // WebSocket and Auth stores are now implemented + // Future stores will be added in subsequent phases: + // show state, script state, etc. } -// Future stores will be imported and exported here in Phase 2: -// export { useUserStore } from './user' -// export { useWebSocketStore } from './websocket' -// export { useShowStore } from './show' -// export { useScriptStore } from './script' +// Create and export the Pinia store instance +const pinia = createPinia(); export { pinia }; export default pinia; diff --git a/client-vue3/src/stores/websocket.ts b/client-vue3/src/stores/websocket.ts new file mode 100644 index 00000000..40dbae63 --- /dev/null +++ b/client-vue3/src/stores/websocket.ts @@ -0,0 +1,192 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; + +export interface WebSocketMessage { + OP?: string; + ACTION?: string; + DATA?: unknown; + namespace?: string; +} + +export interface WebSocketState { + isConnected: boolean; + message: WebSocketMessage | null; + reconnectError: boolean; + internalUUID: string | null; + errorCount: number; + error: boolean; + authenticated: boolean; + authenticationInProgress: boolean; + pendingAuthentication: boolean; + newConnection: boolean; + authSucceeded: boolean; +} + +export const useWebSocketStore = defineStore('websocket', () => { + // State + const isConnected = ref(false); + const message = ref(null); + const reconnectError = ref(false); + const internalUUID = ref(null); + const errorCount = ref(0); + const error = ref(false); + const authenticated = ref(false); + const authenticationInProgress = ref(false); + const pendingAuthentication = ref(false); + const newConnection = ref(false); + const authSucceeded = ref(false); + + // Getters + const websocketHealthy = computed(() => !error.value + && isConnected.value + && !reconnectError.value + && errorCount.value === 0); + + const websocketHasPendingOperations = computed(() => pendingAuthentication.value + || newConnection.value + || authSucceeded.value); + + // Actions + function setConnected(connected: boolean) { + isConnected.value = connected; + } + + function setMessage(msg: WebSocketMessage | null) { + message.value = msg; + } + + function setReconnectError(hasError: boolean) { + reconnectError.value = hasError; + } + + function setInternalUUID(uuid: string | null) { + internalUUID.value = uuid; + } + + function setErrorCount(count: number) { + errorCount.value = count; + } + + function setError(hasError: boolean) { + error.value = hasError; + } + + function setAuthenticated(auth: boolean) { + authenticated.value = auth; + } + + function setAuthenticationInProgress(inProgress: boolean) { + authenticationInProgress.value = inProgress; + } + + function setPendingAuthentication(pending: boolean) { + pendingAuthentication.value = pending; + } + + function setNewConnection(isNew: boolean) { + newConnection.value = isNew; + } + + function setAuthSucceeded(succeeded: boolean) { + authSucceeded.value = succeeded; + } + + function clearWsAuthentication() { + authenticated.value = false; + authSucceeded.value = false; + pendingAuthentication.value = true; + } + + function clearPendingAuthentication() { + pendingAuthentication.value = false; + } + + function clearNewConnection() { + newConnection.value = false; + } + + function clearAuthSucceeded() { + authSucceeded.value = false; + } + + // WebSocket connection event handlers + function handleSocketOpen() { + isConnected.value = true; + error.value = false; + reconnectError.value = false; + + if (errorCount.value !== 0) { + // TODO: Show success toast - will be implemented when we add toast functionality + console.log(`WebSocket reconnected after ${errorCount.value} attempts`); + errorCount.value = 0; + + // TODO: Handle page reload if not on home route - needs router integration + // if (currentRoute !== '/') { + // window.location.reload(); + // } + } + } + + function handleSocketClose() { + isConnected.value = false; + authenticated.value = false; + } + + function handleSocketError() { + error.value = true; + } + + function handleSocketReconnect(count: number) { + if (errorCount.value === 0) { + // TODO: Show error toast + console.error('WebSocket connection lost'); + } + errorCount.value = count; + authenticated.value = false; + } + + function handleSocketReconnectError() { + reconnectError.value = true; + } + + return { + // State + isConnected, + message, + reconnectError, + internalUUID, + errorCount, + error, + authenticated, + authenticationInProgress, + pendingAuthentication, + newConnection, + authSucceeded, + + // Getters + websocketHealthy, + websocketHasPendingOperations, + + // Actions + setConnected, + setMessage, + setReconnectError, + setInternalUUID, + setErrorCount, + setError, + setAuthenticated, + setAuthenticationInProgress, + setPendingAuthentication, + setNewConnection, + setAuthSucceeded, + clearWsAuthentication, + clearPendingAuthentication, + clearNewConnection, + clearAuthSucceeded, + handleSocketOpen, + handleSocketClose, + handleSocketError, + handleSocketReconnect, + handleSocketReconnectError, + }; +}); diff --git a/client-vue3/src/utils/httpInterceptor.ts b/client-vue3/src/utils/httpInterceptor.ts new file mode 100644 index 00000000..4d99ae44 --- /dev/null +++ b/client-vue3/src/utils/httpInterceptor.ts @@ -0,0 +1,131 @@ +import { makeURL } from '@/utils/index'; + +export default function setupHttpInterceptor() { + // Store the original fetch function + const originalFetch = window.fetch; + + let isRefreshingToken = false; + + // Replace with our enhanced version + window.fetch = async (resource: RequestInfo | URL, options: RequestInit = {}) => { + // Only intercept our own API requests + if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) { + // Get token from localStorage directly to avoid Pinia initialization issues + const token = localStorage.getItem('digiscript_auth_token'); + const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); + const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); + + // Clone the options + const newOptions: RequestInit = { + ...options, + headers: { + ...options.headers, + }, + }; + + // Add token if it exists and Authorization header is not already set + if (token && !Object.keys(newOptions.headers || {}).some((key) => key.toLowerCase() === 'authorization')) { + newOptions.headers = { + ...newOptions.headers, + Authorization: `Bearer ${token}`, + }; + } + + // Add content-type if not already set and it's a POST/PUT request + if ((!options.headers || !Object.keys(options.headers).some((key) => key.toLowerCase() === 'content-type')) && (options.method === 'POST' || options.method === 'PUT')) { + newOptions.headers = { + ...newOptions.headers, + 'Content-Type': 'application/json', + }; + } + + try { + // Make the request with enhanced options + const response = await originalFetch(resource, newOptions); + + // Handle 401 Unauthorized errors globally + if (response.status === 401 && !isLogoutRequest) { + console.warn('Received 401 Unauthorized response'); + + if (isRefreshRequest || isRefreshingToken) { + console.warn('Token refresh failed with 401 or already refreshing, logging out'); + // TODO: Add toast notification when toast system is available + console.warn('Your session has expired. Please log in again.'); + // Clear token from localStorage + localStorage.removeItem('digiscript_auth_token'); + return response; + } + + console.info('Attempting token refresh'); + // Try to refresh the token if we have one + if (token) { + try { + isRefreshingToken = true; + + // Try to refresh the token + const refreshResponse = await originalFetch(makeURL('/api/v1/auth/refresh-token'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + + isRefreshingToken = false; + + if (refreshResponse.ok) { + const refreshData = await refreshResponse.json(); + const newToken = refreshData.access_token; + + // Update token in localStorage + localStorage.setItem('digiscript_auth_token', newToken); + + console.info('Token refresh successful, retrying original request'); + // Retry the original request with the new token + const retriedOptions: RequestInit = { + ...newOptions, + headers: { + ...newOptions.headers, + Authorization: `Bearer ${newToken}`, + }, + }; + return await originalFetch(resource, retriedOptions); + } + // If refresh fails, handle unauthorized state + console.warn('Token refresh failed, logging out'); + // TODO: Add toast notification when toast system is available + console.warn('Your session has expired. Please log in again.'); + + // Clear token from localStorage + localStorage.removeItem('digiscript_auth_token'); + + // Return the original 401 response + return response; + } catch (refreshError) { + isRefreshingToken = false; + console.error('Error during token refresh:', refreshError); + // TODO: Add toast notification when toast system is available + console.error('Authentication error - please log in again'); + + // Clear token from localStorage + localStorage.removeItem('digiscript_auth_token'); + return response; + } + } else { + // No token, just return the 401 + console.warn('401 received with no token present'); + return response; + } + } + + return response; + } catch (error) { + console.error('Fetch error:', error); + throw error; + } + } + + // Pass through non-API requests + return originalFetch(resource, options); + }; +} diff --git a/client-vue3/src/utils/index.ts b/client-vue3/src/utils/index.ts new file mode 100644 index 00000000..d49a1fda --- /dev/null +++ b/client-vue3/src/utils/index.ts @@ -0,0 +1,63 @@ +/** + * Utility functions for the Vue 3 DigiScript application + * These functions replicate the functionality from client/src/js/utils.js + */ + +export function baseURL(): string { + return `${window.location.protocol}//${window.location.hostname}:${window.location.port}`; +} + +export function makeURL(path: string): string { + return `${baseURL()}${path}`; +} + +export function titleCase(str: string, sep: string = ' '): string { + const splitStr = str.toLowerCase().split(sep); + for (let i = 0; i < splitStr.length; i += 1) { + splitStr[i] = splitStr[i].charAt(0).toUpperCase() + splitStr[i].substring(1); + } + return splitStr.join(' '); +} + +export function randInt(min: number, max: number): number { + const minCeil = Math.ceil(min); + const maxFloor = Math.floor(max); + return Math.floor(Math.random() * (maxFloor - minCeil) + minCeil); +} + +export function msToTimerString(milliseconds: number): string { + // Adapted from https://stackoverflow.com/a/33909506 + const hours = milliseconds / (1000 * 60 * 60); + const absoluteHours = Math.floor(hours); + const h = absoluteHours > 9 ? absoluteHours : `0${absoluteHours}`; + const minutes = (hours - absoluteHours) * 60; + const absoluteMinutes = Math.floor(minutes); + const m = absoluteMinutes > 9 ? absoluteMinutes : `0${absoluteMinutes}`; + const seconds = (minutes - absoluteMinutes) * 60; + const absoluteSeconds = Math.floor(seconds); + const s = absoluteSeconds > 9 ? absoluteSeconds : `0${absoluteSeconds}`; + + return `${h}:${m}:${s}`; +} + +export function msToTimerParts(milliseconds: number): [number, number, number] { + // Adapted from https://stackoverflow.com/a/33909506 + const hours = milliseconds / (1000 * 60 * 60); + const absoluteHours = Math.floor(hours); + const minutes = (hours - absoluteHours) * 60; + const absoluteMinutes = Math.floor(minutes); + const seconds = (minutes - absoluteMinutes) * 60; + const absoluteSeconds = Math.floor(seconds); + return [absoluteHours, absoluteMinutes, absoluteSeconds]; +} + +export function formatTimerParts( + hours: number, + minutes: number, + seconds: number, +): [string, string, string] { + const h = hours > 9 ? hours.toString() : `0${hours}`; + const m = minutes > 9 ? minutes.toString() : `0${minutes}`; + const s = seconds > 9 ? seconds.toString() : `0${seconds}`; + return [h, m, s]; +} diff --git a/client-vue3/src/views/HomeView.vue b/client-vue3/src/views/HomeView.vue index f3d22ab5..0a7037ea 100644 --- a/client-vue3/src/views/HomeView.vue +++ b/client-vue3/src/views/HomeView.vue @@ -29,27 +29,45 @@
-

Next Phases

+

Phase 2 Progress

    +
  • ✅ Pinia State Management
  • +
  • ✅ WebSocket Communication
  • +
  • ✅ Authentication System
  • ⏳ Bootstrap Vue 3 Integration
  • -
  • ⏳ WebSocket Communication
  • -
  • ⏳ Authentication System
  • ⏳ Component Migration
  • -
  • ⏳ Store Migration
+ +
-

Phase 1 Complete

+

Phase 2: State Management & WebSocket

+

+ COMPLETED: WebSocket communication and state management have been + successfully migrated to Vue 3. + This includes Pinia stores for authentication and WebSocket state, with full + OP/ACTION message routing compatibility. +

- This Vue 3 application runs alongside the existing Vue 2 application without interference. - The Vue 2 app continues to serve at / while this Vue 3 app serves at - /v3/. + The WebSocket implementation maintains 100% compatibility with the existing backend API, + preserving all message formats and authentication flows used by the Vue 2 application.

- Future phases will gradually migrate functionality from Vue 2 to Vue 3, - maintaining full backward compatibility until the migration is complete. + Test the implementation: Use the WebSocket test page above to verify + connection, + authentication, and message handling functionality.

@@ -136,4 +154,41 @@ border-radius: 4px; font-family: 'Courier New', monospace; } + +.test-links { + margin-bottom: 3rem; + text-align: center; +} + +.test-links h2 { + color: #2d3748; + margin-bottom: 1.5rem; +} + +.link-buttons { + display: flex; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; +} + +.test-link { + display: inline-block; + padding: 1rem 1.5rem; + background: #007bff; + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2); +} + +.test-link:hover { + background: #0056b3; + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 123, 255, 0.3); + color: white; + text-decoration: none; +} diff --git a/client-vue3/src/views/WebSocketTest.vue b/client-vue3/src/views/WebSocketTest.vue new file mode 100644 index 00000000..cf084714 --- /dev/null +++ b/client-vue3/src/views/WebSocketTest.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/client-vue3/vite.config.ts b/client-vue3/vite.config.ts index eb366489..4ec285ba 100644 --- a/client-vue3/vite.config.ts +++ b/client-vue3/vite.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ secure: false, }, // Proxy WebSocket connections - '/ws': { + '/api/v1/ws': { target: 'ws://localhost:8080', ws: true, changeOrigin: true,