diff --git a/.gitignore b/.gitignore
index af215c07f..721105786 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
**/.DS_Store
.claude
+CLAUDE.md
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
@@ -11,6 +12,7 @@
.idea/**/dictionaries
.idea/**/shelf
+
# AWS User-specific
.idea/**/aws.xml
diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
new file mode 100644
index 000000000..4ea72a911
--- /dev/null
+++ b/.idea/copilot.data.migration.agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml
new file mode 100644
index 000000000..7ef04e2ea
--- /dev/null
+++ b/.idea/copilot.data.migration.ask.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml
new file mode 100644
index 000000000..8648f9401
--- /dev/null
+++ b/.idea/copilot.data.migration.edit.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
new file mode 100644
index 000000000..a5d4884e5
--- /dev/null
+++ b/.idea/material_theme_project_new.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/package-lock.json b/client/package-lock.json
index 99e3dcf16..8dcc8435b 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,18 +1,18 @@
{
"name": "client",
- "version": "0.17.10",
+ "version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client",
- "version": "0.17.10",
+ "version": "0.18.0",
"dependencies": {
"bootstrap": "4.6.2",
"bootstrap-vue": "2.23.1",
"bootswatch": "4.6.2",
"contrast-color": "1.0.1",
- "core-js": "3.44.0",
+ "core-js": "3.47.0",
"deep-object-diff": "1.1.9",
"jquery": "3.7.1",
"lodash": "4.17.21",
@@ -27,11 +27,11 @@
"vuex-persistedstate": "3.2.1"
},
"devDependencies": {
- "@babel/core": "7.28.0",
- "@babel/eslint-parser": "7.28.0",
- "@babel/preset-env": "7.28.0",
+ "@babel/core": "7.28.5",
+ "@babel/eslint-parser": "7.28.5",
+ "@babel/preset-env": "7.28.5",
"@types/vuelidate": "^0.7.22",
- "@vitejs/plugin-vue2": "2.3.3",
+ "@vitejs/plugin-vue2": "2.3.4",
"eslint": "8.57.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-import-resolver-alias": "1.1.2",
@@ -39,7 +39,7 @@
"eslint-plugin-vue": "^9.27.0",
"eslint-plugin-vuejs-accessibility": "1.2.0",
"node-sass": "7.0.3",
- "sass": "1.89.2",
+ "sass": "1.94.2",
"sass-loader": "13.3.3",
"vite": "4.5.5"
},
@@ -48,20 +48,6 @@
"npm": ">=10.0.0 <11"
}
},
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -78,9 +64,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
- "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
+ "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -88,22 +74,22 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
- "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
+ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.0",
+ "@babel/generator": "^7.28.5",
"@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.27.3",
- "@babel/helpers": "^7.27.6",
- "@babel/parser": "^7.28.0",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.5",
"@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.0",
- "@babel/types": "^7.28.0",
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -119,9 +105,9 @@
}
},
"node_modules/@babel/eslint-parser": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.0.tgz",
- "integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.5.tgz",
+ "integrity": "sha512-fcdRcWahONYo+JRnJg1/AekOacGvKx12Gu0qXJXFi2WBqQA1i7+O5PaxRB7kxE/Op94dExnCiiar6T09pvdHpA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -138,14 +124,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
- "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
+ "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.0",
- "@babel/types": "^7.28.0",
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -185,18 +171,18 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz",
- "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz",
+ "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-annotate-as-pure": "^7.27.1",
- "@babel/helper-member-expression-to-functions": "^7.27.1",
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
- "@babel/traverse": "^7.27.1",
+ "@babel/traverse": "^7.28.5",
"semver": "^6.3.1"
},
"engines": {
@@ -252,14 +238,14 @@
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
- "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
+ "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -280,15 +266,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.27.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
- "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.27.3"
+ "@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@@ -380,9 +366,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -414,26 +400,26 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.27.6",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
- "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
- "@babel/types": "^7.27.6"
+ "@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
- "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.0"
+ "@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -443,14 +429,14 @@
}
},
"node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz",
- "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
+ "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
- "@babel/traverse": "^7.27.1"
+ "@babel/traverse": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -510,14 +496,14 @@
}
},
"node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz",
- "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz",
+ "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
- "@babel/traverse": "^7.27.1"
+ "@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@@ -657,9 +643,9 @@
}
},
"node_modules/@babel/plugin-transform-block-scoping": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz",
- "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz",
+ "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -690,13 +676,13 @@
}
},
"node_modules/@babel/plugin-transform-class-static-block": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz",
- "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz",
+ "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-create-class-features-plugin": "^7.27.1",
+ "@babel/helper-create-class-features-plugin": "^7.28.3",
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
@@ -707,9 +693,9 @@
}
},
"node_modules/@babel/plugin-transform-classes": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz",
- "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz",
+ "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -718,7 +704,7 @@
"@babel/helper-globals": "^7.28.0",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1",
- "@babel/traverse": "^7.28.0"
+ "@babel/traverse": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
@@ -745,14 +731,14 @@
}
},
"node_modules/@babel/plugin-transform-destructuring": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz",
- "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz",
+ "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
- "@babel/traverse": "^7.28.0"
+ "@babel/traverse": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -845,9 +831,9 @@
}
},
"node_modules/@babel/plugin-transform-exponentiation-operator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz",
- "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz",
+ "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -944,9 +930,9 @@
}
},
"node_modules/@babel/plugin-transform-logical-assignment-operators": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz",
- "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz",
+ "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1010,16 +996,16 @@
}
},
"node_modules/@babel/plugin-transform-modules-systemjs": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz",
- "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
+ "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-transforms": "^7.27.1",
+ "@babel/helper-module-transforms": "^7.28.3",
"@babel/helper-plugin-utils": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -1111,9 +1097,9 @@
}
},
"node_modules/@babel/plugin-transform-object-rest-spread": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz",
- "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz",
+ "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1121,7 +1107,7 @@
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0",
"@babel/plugin-transform-parameters": "^7.27.7",
- "@babel/traverse": "^7.28.0"
+ "@babel/traverse": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
@@ -1164,9 +1150,9 @@
}
},
"node_modules/@babel/plugin-transform-optional-chaining": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz",
- "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz",
+ "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1248,9 +1234,9 @@
}
},
"node_modules/@babel/plugin-transform-regenerator": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz",
- "integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz",
+ "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1445,21 +1431,21 @@
}
},
"node_modules/@babel/preset-env": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz",
- "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz",
+ "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.28.0",
+ "@babel/compat-data": "^7.28.5",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
- "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5",
"@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1",
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1",
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1",
- "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3",
"@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
"@babel/plugin-syntax-import-assertions": "^7.27.1",
"@babel/plugin-syntax-import-attributes": "^7.27.1",
@@ -1468,42 +1454,42 @@
"@babel/plugin-transform-async-generator-functions": "^7.28.0",
"@babel/plugin-transform-async-to-generator": "^7.27.1",
"@babel/plugin-transform-block-scoped-functions": "^7.27.1",
- "@babel/plugin-transform-block-scoping": "^7.28.0",
+ "@babel/plugin-transform-block-scoping": "^7.28.5",
"@babel/plugin-transform-class-properties": "^7.27.1",
- "@babel/plugin-transform-class-static-block": "^7.27.1",
- "@babel/plugin-transform-classes": "^7.28.0",
+ "@babel/plugin-transform-class-static-block": "^7.28.3",
+ "@babel/plugin-transform-classes": "^7.28.4",
"@babel/plugin-transform-computed-properties": "^7.27.1",
- "@babel/plugin-transform-destructuring": "^7.28.0",
+ "@babel/plugin-transform-destructuring": "^7.28.5",
"@babel/plugin-transform-dotall-regex": "^7.27.1",
"@babel/plugin-transform-duplicate-keys": "^7.27.1",
"@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1",
"@babel/plugin-transform-dynamic-import": "^7.27.1",
"@babel/plugin-transform-explicit-resource-management": "^7.28.0",
- "@babel/plugin-transform-exponentiation-operator": "^7.27.1",
+ "@babel/plugin-transform-exponentiation-operator": "^7.28.5",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-for-of": "^7.27.1",
"@babel/plugin-transform-function-name": "^7.27.1",
"@babel/plugin-transform-json-strings": "^7.27.1",
"@babel/plugin-transform-literals": "^7.27.1",
- "@babel/plugin-transform-logical-assignment-operators": "^7.27.1",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.28.5",
"@babel/plugin-transform-member-expression-literals": "^7.27.1",
"@babel/plugin-transform-modules-amd": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
- "@babel/plugin-transform-modules-systemjs": "^7.27.1",
+ "@babel/plugin-transform-modules-systemjs": "^7.28.5",
"@babel/plugin-transform-modules-umd": "^7.27.1",
"@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1",
"@babel/plugin-transform-new-target": "^7.27.1",
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
"@babel/plugin-transform-numeric-separator": "^7.27.1",
- "@babel/plugin-transform-object-rest-spread": "^7.28.0",
+ "@babel/plugin-transform-object-rest-spread": "^7.28.4",
"@babel/plugin-transform-object-super": "^7.27.1",
"@babel/plugin-transform-optional-catch-binding": "^7.27.1",
- "@babel/plugin-transform-optional-chaining": "^7.27.1",
+ "@babel/plugin-transform-optional-chaining": "^7.28.5",
"@babel/plugin-transform-parameters": "^7.27.7",
"@babel/plugin-transform-private-methods": "^7.27.1",
"@babel/plugin-transform-private-property-in-object": "^7.27.1",
"@babel/plugin-transform-property-literals": "^7.27.1",
- "@babel/plugin-transform-regenerator": "^7.28.0",
+ "@babel/plugin-transform-regenerator": "^7.28.4",
"@babel/plugin-transform-regexp-modifiers": "^7.27.1",
"@babel/plugin-transform-reserved-words": "^7.27.1",
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
@@ -1560,18 +1546,18 @@
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
- "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
+ "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.0",
+ "@babel/generator": "^7.28.5",
"@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.0",
+ "@babel/parser": "^7.28.5",
"@babel/template": "^7.27.2",
- "@babel/types": "^7.28.0",
+ "@babel/types": "^7.28.5",
"debug": "^4.3.1"
},
"engines": {
@@ -1579,13 +1565,13 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
- "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2126,6 +2112,17 @@
"@jridgewell/trace-mapping": "^0.3.24"
}
},
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -2724,16 +2721,16 @@
"license": "ISC"
},
"node_modules/@vitejs/plugin-vue2": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue2/-/plugin-vue2-2.3.3.tgz",
- "integrity": "sha512-qexY6+bbwY8h0AZerzUuGywNTi0cLOkbiSbggr0R3WEW95iB2hblQFyv4MAkkc2vm4gZN1cO5kzT1Kp6xlVzZw==",
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue2/-/plugin-vue2-2.3.4.tgz",
+ "integrity": "sha512-LgqtRRedJb1KdmgcllwGX0gtlPvOvtR6pITXmqxGwQhBZaAysg0Hd7wvj3sjCsj4+PENWsqS7O+ceYSOgJ+H9g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >= 16.0.0"
},
"peerDependencies": {
- "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"vue": "^2.7.0-0"
}
},
@@ -3830,9 +3827,9 @@
"license": "MIT"
},
"node_modules/core-js": {
- "version": "3.44.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz",
- "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==",
+ "version": "3.47.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
+ "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
@@ -8153,9 +8150,9 @@
"license": "MIT"
},
"node_modules/sass": {
- "version": "1.89.2",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz",
- "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==",
+ "version": "1.94.2",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz",
+ "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/client/package.json b/client/package.json
index 551c2172c..74feb636c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "client",
- "version": "0.17.10",
+ "version": "0.18.0",
"private": true,
"scripts": {
"build": "vite build",
@@ -17,7 +17,7 @@
"bootstrap-vue": "2.23.1",
"bootswatch": "4.6.2",
"contrast-color": "1.0.1",
- "core-js": "3.44.0",
+ "core-js": "3.47.0",
"deep-object-diff": "1.1.9",
"jquery": "3.7.1",
"lodash": "4.17.21",
@@ -32,11 +32,11 @@
"vuex-persistedstate": "3.2.1"
},
"devDependencies": {
- "@babel/core": "7.28.0",
- "@babel/eslint-parser": "7.28.0",
- "@babel/preset-env": "7.28.0",
+ "@babel/core": "7.28.5",
+ "@babel/eslint-parser": "7.28.5",
+ "@babel/preset-env": "7.28.5",
"@types/vuelidate": "^0.7.22",
- "@vitejs/plugin-vue2": "2.3.3",
+ "@vitejs/plugin-vue2": "2.3.4",
"eslint": "8.57.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-import-resolver-alias": "1.1.2",
@@ -44,7 +44,7 @@
"eslint-plugin-vue": "^9.27.0",
"eslint-plugin-vuejs-accessibility": "1.2.0",
"node-sass": "7.0.3",
- "sass": "1.89.2",
+ "sass": "1.94.2",
"sass-loader": "13.3.3",
"vite": "4.5.5"
},
diff --git a/client/src/store/modules/user/user.js b/client/src/store/modules/user/user.js
index 1d8028a34..c28b8c624 100644
--- a/client/src/store/modules/user/user.js
+++ b/client/src/store/modules/user/user.js
@@ -207,6 +207,49 @@ export default {
await context.commit('SET_TOKEN_REFRESH_INTERVAL', refreshInterval);
},
+ async GENERATE_API_TOKEN(context) {
+ const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ });
+ if (response.ok) {
+ const data = await response.json();
+ Vue.$toast.success('API token generated successfully!');
+ return data;
+ }
+ const responseBody = await response.json();
+ log.error('Unable to generate API token');
+ Vue.$toast.error(`Unable to generate API token: ${responseBody.message || 'Unknown error'}`);
+ return null;
+ },
+ async REVOKE_API_TOKEN(context) {
+ const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ });
+ if (response.ok) {
+ Vue.$toast.success('API token revoked successfully!');
+ return true;
+ }
+ const responseBody = await response.json();
+ log.error('Unable to revoke API token');
+ Vue.$toast.error(`Unable to revoke API token: ${responseBody.message || 'Unknown error'}`);
+ return false;
+ },
+ async GET_API_TOKEN(context) {
+ const response = await fetch(makeURL('/api/v1/auth/api-token'), {
+ method: 'GET',
+ });
+ if (response.ok) {
+ const data = await response.json();
+ return data;
+ }
+ log.error('Unable to get API token');
+ Vue.$toast.error('Unable to get API token!');
+ return null;
+ },
},
getters: {
CURRENT_USER(state) {
diff --git a/client/src/views/show/ShowLiveView.vue b/client/src/views/show/ShowLiveView.vue
index 859380415..f1a120906 100644
--- a/client/src/views/show/ShowLiveView.vue
+++ b/client/src/views/show/ShowLiveView.vue
@@ -200,6 +200,7 @@
:id="`new-cue-modal`"
title="Add New Cue"
size="md"
+ :ok-disabled="$v.newCueFormState.$invalid || submittingNewCue"
@hidden="resetNewCueForm"
@ok="onSubmitNewCue"
>
@@ -309,6 +310,7 @@ export default {
ident: null,
lineId: null,
},
+ submittingNewCue: false,
};
},
validations: {
@@ -759,6 +761,8 @@ export default {
// Process arrow keys
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
this.handleKeyNavigation(event);
+ } else if (event.key === 'PageUp' || event.key === 'PageDown') {
+ this.handlePageNavigation(event);
} else if (event.key === 'C') {
this.handleCueEditToggle(event);
}
@@ -787,6 +791,28 @@ export default {
const delta = event.key === 'ArrowDown' ? 1 : -1;
this.navigateRelative(0, delta);
},
+ handlePageNavigation(event) {
+ // Only handle if we're the leader and not currently scrolling or starting an interval
+ if (!this.isScriptLeader || !this.initialLoad || this.isScrollingProgrammatically
+ || this.intervalTimerContext != null || this.CURRENT_SHOW_INTERVAL != null) return;
+
+ event.preventDefault();
+
+ // Navigate by page increments
+ const isPageDown = event.key === 'PageDown';
+ let targetPage = this.currentPage + (isPageDown ? 1 : -1);
+
+ // Ensure we stay within valid page bounds
+ if (targetPage < 1) {
+ targetPage = 1;
+ } else if (targetPage > this.currentLoadedPage) {
+ // Don't navigate beyond loaded pages
+ return;
+ }
+
+ // Navigate to the first line of the target page
+ this.navigateTo(targetPage, 0);
+ },
handleWheelNavigation(event) {
// Only handle if we're the leader and not currently scrolling or starting an interval
if (!this.isScriptLeader || !this.initialLoad || this.isScrollingProgrammatically
@@ -1136,6 +1162,7 @@ export default {
ident: null,
lineId: null,
};
+ this.submittingNewCue = false;
this.$nextTick(() => {
this.$v.$reset();
@@ -1147,11 +1174,21 @@ export default {
},
async onSubmitNewCue(event) {
this.$v.newCueFormState.$touch();
- if (this.$v.newCueFormState.$anyError) {
+ if (this.$v.newCueFormState.$anyError || this.submittingNewCue) {
event.preventDefault();
- } else {
+ return;
+ }
+
+ this.submittingNewCue = true;
+ try {
await this.ADD_NEW_CUE(this.newCueFormState);
+ this.$bvModal.hide('new-cue-modal');
this.resetNewCueForm();
+ } catch (error) {
+ log.error('Error submitting new cue:', error);
+ event.preventDefault();
+ } finally {
+ this.submittingNewCue = false;
}
},
...mapActions(['GET_SHOW_SESSION_DATA', 'LOAD_SCRIPT_PAGE', 'GET_ACT_LIST', 'GET_SCENE_LIST',
diff --git a/client/src/views/show/config/ConfigActs.vue b/client/src/views/show/config/ConfigActs.vue
index 1a269acb5..66ab0c2e8 100644
--- a/client/src/views/show/config/ConfigActs.vue
+++ b/client/src/views/show/config/ConfigActs.vue
@@ -53,12 +53,14 @@
Edit
Delete
@@ -81,6 +83,7 @@
ref="new-act"
title="Add New Act"
size="md"
+ :ok-disabled="$v.newFormState.$invalid || submittingNewAct"
@show="resetNewForm"
@hidden="resetNewForm"
@ok="onSubmitNew"
@@ -137,6 +140,7 @@
ref="edit-act"
title="Edit Act"
size="md"
+ :ok-disabled="$v.editFormState.$invalid || submittingEditAct"
@hidden="resetEditForm"
@ok="onSubmitEdit"
>
@@ -199,6 +203,7 @@
diff --git a/client/src/vue_components/config/ConfigSystem.vue b/client/src/vue_components/config/ConfigSystem.vue
index d4dfc1c56..edd23df11 100644
--- a/client/src/vue_components/config/ConfigSystem.vue
+++ b/client/src/vue_components/config/ConfigSystem.vue
@@ -52,6 +52,7 @@
id="show-config"
ref="modal"
title="Setup New Show"
+ :ok-disabled="isSubmittingShow"
@show="resetForm"
@hidden="resetForm"
@ok="onSubmit"
@@ -129,12 +130,14 @@
Save and Load
Save
@@ -159,6 +162,7 @@
Load Show
@@ -212,6 +216,8 @@ export default {
return {
perPage: 5,
currentPageShows: 1,
+ isSubmittingShow: false,
+ isSubmittingLoad: false,
formState: {
name: null,
start: null,
@@ -298,6 +304,7 @@ export default {
start: null,
end: null,
};
+ this.isSubmittingShow = false;
this.$nextTick(() => {
this.$v.$reset();
@@ -316,44 +323,72 @@ export default {
return;
}
- const searchParams = new URLSearchParams({
- load,
- });
- const response = await fetch(`${makeURL('/api/v1/show')}?${searchParams}`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(this.formState),
- });
- if (response.ok) {
- const settings = await response.json();
- await this.getAvailableShows();
- this.$toast.success('Created new show!');
- this.$bvModal.hide('show-config');
- this.resetForm();
- } else {
+ if (this.isSubmittingShow) {
+ event.preventDefault();
+ return;
+ }
+
+ this.isSubmittingShow = true;
+
+ try {
+ const searchParams = new URLSearchParams({
+ load,
+ });
+ const response = await fetch(`${makeURL('/api/v1/show')}?${searchParams}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(this.formState),
+ });
+ if (response.ok) {
+ const settings = await response.json();
+ await this.getAvailableShows();
+ this.$toast.success('Created new show!');
+ this.$bvModal.hide('show-config');
+ this.resetForm();
+ } else {
+ this.$toast.error('Unable to save show');
+ log.error('Unable to create new show');
+ event.preventDefault();
+ }
+ } catch (error) {
this.$toast.error('Unable to save show');
- log.error('Unable to create new show');
+ log.error('Error creating new show:', error);
event.preventDefault();
+ } finally {
+ this.isSubmittingShow = false;
}
},
async loadShow(show) {
- const response = await fetch(`${makeURL('/api/v1/settings')}`, {
- method: 'PATCH',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- current_show: show.id,
- }),
- });
- if (response.ok) {
- this.$toast.success('Loaded show!');
- this.$bvModal.hide('show-load');
- } else {
+ if (this.isSubmittingLoad) {
+ return;
+ }
+
+ this.isSubmittingLoad = true;
+
+ try {
+ const response = await fetch(`${makeURL('/api/v1/settings')}`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ current_show: show.id,
+ }),
+ });
+ if (response.ok) {
+ this.$toast.success('Loaded show!');
+ this.$bvModal.hide('show-load');
+ } else {
+ this.$toast.error('Unable to load show');
+ log.error('Unable to load show');
+ }
+ } catch (error) {
this.$toast.error('Unable to load show');
- log.error('Unable to load show');
+ log.error('Error loading show:', error);
+ } finally {
+ this.isSubmittingLoad = false;
}
},
...mapMutations(['UPDATE_SHOWS']),
diff --git a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
index 8ac1f7f19..2d1cb4784 100644
--- a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
+++ b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
@@ -92,6 +92,7 @@
:id="`line_${lineIndex}_-new-cue`"
title="Add New Cue"
size="md"
+ :ok-disabled="$v.newFormState.$invalid || submittingNewCue"
@hidden="resetNewForm"
@ok="onSubmitNew"
>
@@ -206,6 +207,7 @@
:id="`line_${lineIndex}_-edit-cue`"
title="Edit Cue"
size="md"
+ :ok-disabled="$v.editFormState.$invalid || submittingEditCue"
@hidden="resetEditForm"
@ok="onSubmitEdit"
>
@@ -259,18 +261,21 @@
Cancel
Delete
Save
@@ -284,6 +289,7 @@
import { required } from 'vuelidate/lib/validators';
import { mapActions, mapGetters } from 'vuex';
import { contrastColor } from 'contrast-color';
+import log from 'loglevel';
export default {
name: 'ScriptLineCueEditor',
@@ -350,6 +356,9 @@ export default {
ident: null,
lineId: null,
},
+ submittingNewCue: false,
+ submittingEditCue: false,
+ deletingCue: false,
};
},
validations: {
@@ -492,6 +501,7 @@ export default {
ident: null,
lineId: null,
};
+ this.submittingNewCue = false;
this.$nextTick(() => {
this.$v.$reset();
@@ -503,12 +513,21 @@ export default {
},
async onSubmitNew(event) {
this.$v.newFormState.$touch();
- if (this.$v.newFormState.$anyError) {
+ if (this.$v.newFormState.$anyError || this.submittingNewCue) {
event.preventDefault();
- } else {
+ return;
+ }
+
+ this.submittingNewCue = true;
+ try {
await this.ADD_NEW_CUE(this.newFormState);
this.$bvModal.hide(`line_${this.lineIndex}_-new-cue`);
this.resetNewForm();
+ } catch (error) {
+ log.error('Error submitting new cue:', error);
+ event.preventDefault();
+ } finally {
+ this.submittingNewCue = false;
}
},
openEditForm(cue) {
@@ -526,6 +545,8 @@ export default {
ident: null,
lineId: null,
};
+ this.submittingEditCue = false;
+ this.deletingCue = false;
this.$nextTick(() => {
this.$v.$reset();
@@ -537,28 +558,45 @@ export default {
},
async onSubmitEdit(event) {
this.$v.editFormState.$touch();
- if (this.$v.editFormState.$anyError) {
+ if (this.$v.editFormState.$anyError || this.submittingEditCue) {
event.preventDefault();
- } else {
+ return;
+ }
+
+ this.submittingEditCue = true;
+ try {
await this.EDIT_CUE(this.editFormState);
this.$bvModal.hide(`line_${this.lineIndex}_-edit-cue`);
this.resetEditForm();
+ } catch (error) {
+ log.error('Error submitting edit cue:', error);
+ event.preventDefault();
+ } finally {
+ this.submittingEditCue = false;
}
},
async deleteCue(event) {
this.$v.editFormState.$touch();
- if (this.$v.editFormState.$anyError) {
+ if (this.$v.editFormState.$anyError || this.deletingCue) {
event.preventDefault();
- } else {
- const msg = 'Are you sure you want to delete this cue?';
- const action = await this.$bvModal.msgBoxConfirm(msg, {});
- if (action === true) {
+ return;
+ }
+
+ const msg = 'Are you sure you want to delete this cue?';
+ const action = await this.$bvModal.msgBoxConfirm(msg, {});
+ if (action === true) {
+ this.deletingCue = true;
+ try {
await this.DELETE_CUE({
cueId: this.editFormState.cueId,
lineId: this.editFormState.lineId,
});
this.$bvModal.hide(`line_${this.lineIndex}_-edit-cue`);
this.resetEditForm();
+ } catch (error) {
+ log.error('Error deleting cue:', error);
+ } finally {
+ this.deletingCue = false;
}
}
},
diff --git a/client/src/vue_components/show/config/mics/MicList.vue b/client/src/vue_components/show/config/mics/MicList.vue
index 37f8a1178..1b6e1cbf5 100644
--- a/client/src/vue_components/show/config/mics/MicList.vue
+++ b/client/src/vue_components/show/config/mics/MicList.vue
@@ -27,6 +27,7 @@
Delete
@@ -47,6 +48,7 @@
ref="new-microphone"
title="Add Microphone"
size="md"
+ :ok-disabled="isSubmittingNewMicrophone"
@show="resetNewMicrophoneForm"
@hidden="resetNewMicrophoneForm"
@ok="onSubmitNewMicrophone"
@@ -98,6 +100,7 @@
ref="edit-microphone"
title="Edit Microphone"
size="md"
+ :ok-disabled="isSubmittingEditMicrophone"
@hidden="resetEditMicrophoneForm"
@ok="onSubmitEditMicrophone"
>
@@ -149,6 +152,7 @@
+
+
diff --git a/client/src/vue_components/user/settings/StageDirectionStyles.vue b/client/src/vue_components/user/settings/StageDirectionStyles.vue
index 56d836104..0c93342a1 100644
--- a/client/src/vue_components/user/settings/StageDirectionStyles.vue
+++ b/client/src/vue_components/user/settings/StageDirectionStyles.vue
@@ -20,7 +20,7 @@
id="new-override-select"
ref="new-override-select"
title="Add New Override"
- :ok-disabled="newStyleFormState.styleId == null"
+ :ok-disabled="newStyleFormState.styleId == null || isSubmittingNew"
@show="resetOverrideSelect"
@ok="openNewOverrideModal"
>
@@ -36,6 +36,7 @@
ref="new-override-modal"
title="Add New Override"
size="lg"
+ :ok-disabled="isSubmittingNew"
@hidden="resetNewFormState"
@ok="onSubmitNewOverride"
>
@@ -154,6 +155,7 @@
ref="edit-override-modal"
title="Edit Override"
size="lg"
+ :ok-disabled="isSubmittingEdit"
@hidden="resetEditFormState"
@ok="onSubmitEditOverride"
>
@@ -291,12 +293,14 @@
Edit
Delete
@@ -353,6 +357,9 @@ export default {
enableBackgroundColour: false,
backgroundColour: '#000000',
},
+ isSubmittingNew: false,
+ isSubmittingEdit: false,
+ isDeleting: false,
};
},
computed: {
@@ -475,6 +482,7 @@ export default {
enableBackgroundColour: false,
backgroundColour: '#000000',
};
+ this.isSubmittingNew = false;
this.$nextTick(() => {
this.$v.$reset();
});
@@ -492,6 +500,7 @@ export default {
enableBackgroundColour: false,
backgroundColour: '#000000',
};
+ this.isSubmittingEdit = false;
this.$nextTick(() => {
this.$v.$reset();
});
@@ -500,18 +509,52 @@ export default {
this.$v.newStyleFormState.$touch();
if (this.$v.newStyleFormState.$anyError) {
event.preventDefault();
- } else {
+ return;
+ }
+
+ if (this.isSubmittingNew) {
+ event.preventDefault();
+ return;
+ }
+
+ this.isSubmittingNew = true;
+
+ try {
await this.ADD_STAGE_DIRECTION_STYLE_OVERRIDE(this.createPayload);
+ this.$refs['new-override-modal'].hide();
this.resetNewFormState();
+ } catch (error) {
+ log.error('Error adding new stage direction style override:', error);
+ this.$toast.error('Failed to add new override');
+ event.preventDefault();
+ } finally {
+ this.isSubmittingNew = false;
}
},
async onSubmitEditOverride(event) {
this.$v.editStyleFormState.$touch();
if (this.$v.editStyleFormState.$anyError) {
event.preventDefault();
- } else {
+ return;
+ }
+
+ if (this.isSubmittingEdit) {
+ event.preventDefault();
+ return;
+ }
+
+ this.isSubmittingEdit = true;
+
+ try {
await this.UPDATE_STAGE_DIRECTION_STYLE_OVERRIDE(this.editPayload);
+ this.$refs['edit-override-modal'].hide();
this.resetEditFormState();
+ } catch (error) {
+ log.error('Error updating stage direction style override:', error);
+ this.$toast.error('Failed to update override');
+ event.preventDefault();
+ } finally {
+ this.isSubmittingEdit = false;
}
},
validateNewStyleState(name) {
@@ -523,10 +566,22 @@ export default {
return $dirty ? !$error : null;
},
async deleteStyleOverride(style) {
+ if (this.isDeleting) {
+ return;
+ }
+
const msg = 'Are you sure you want to delete this override?';
const action = await this.$bvModal.msgBoxConfirm(msg, {});
if (action === true) {
- await this.DELETE_STAGE_DIRECTION_STYLE_OVERRIDE(style.item.id);
+ this.isDeleting = true;
+ try {
+ await this.DELETE_STAGE_DIRECTION_STYLE_OVERRIDE(style.item.id);
+ } catch (error) {
+ log.error('Error deleting stage direction style override:', error);
+ this.$toast.error('Failed to delete override');
+ } finally {
+ this.isDeleting = false;
+ }
}
},
openEditStyleForm(style) {
diff --git a/server/.pylintrc b/server/.pylintrc
deleted file mode 100644
index a4b01e8e2..000000000
--- a/server/.pylintrc
+++ /dev/null
@@ -1,35 +0,0 @@
-[MASTER]
-init-hook="from pylint.config import find_default_config_files; import sys; sys.path.append(next(find_default_config_files()).parent.as_posix())"
-ignore-paths=^alembic_config/.*$,^test/.*$
-
-[MESSAGES CONTROL]
-disable=
- logging-fstring-interpolation,
- missing-module-docstring,
- missing-class-docstring,
- missing-function-docstring,
- too-few-public-methods,
- duplicate-code,
- too-many-return-statements,
- too-many-branches,
- too-many-statements,
- unnecessary-lambda,
- too-many-locals,
- too-many-nested-blocks,
- too-many-arguments,
- unnecessary-dunder-call,
- broad-exception-raised,
- broad-exception-caught,
- fixme
-
-[FORMAT]
-max-line-length=120
-
-[DESIGN]
-max-args=15
-max-locals=20
-max-returns=20
-max-branches=20
-max-statements=50
-max-attributes=20
-max-positional-arguments=15
diff --git a/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py
new file mode 100644
index 000000000..ce1e13cc7
--- /dev/null
+++ b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py
@@ -0,0 +1,36 @@
+"""Add user API token
+
+Revision ID: e1a2b3c4d5e6
+Revises: 8c78b9c89ee6
+Create Date: 2025-11-27 20:45:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "e1a2b3c4d5e6"
+down_revision: Union[str, None] = "8c78b9c89ee6"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("api_token", sa.String(), nullable=True))
+ batch_op.create_index("ix_user_api_token", ["api_token"], unique=False)
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.drop_index("ix_user_api_token")
+ batch_op.drop_column("api_token")
+
+ # ### end Alembic commands ###
diff --git a/server/controllers/api/auth.py b/server/controllers/api/auth.py
index 0556a0414..32ca5ab4d 100644
--- a/server/controllers/api/auth.py
+++ b/server/controllers/api/auth.py
@@ -1,3 +1,4 @@
+import secrets
from datetime import datetime, timezone
import bcrypt
@@ -272,3 +273,76 @@ class AuthHandler(BaseAPIController):
def get(self):
self.set_status(200)
self.finish(self.current_user if self.current_user else {})
+
+
+@ApiRoute("auth/api-token/generate", ApiVersion.V1)
+class ApiTokenGenerateController(BaseAPIController):
+ @api_authenticated
+ async def post(self):
+ """Generate a new API token for the authenticated user"""
+ with self.make_session() as session:
+ user = session.query(User).get(self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ # Generate a secure random token (plain text to return to user)
+ new_token = secrets.token_urlsafe(32)
+
+ # Hash the token before storing (like passwords)
+ hashed_token = await IOLoop.current().run_in_executor(
+ None, bcrypt.hashpw, escape.utf8(new_token), bcrypt.gensalt()
+ )
+ hashed_token = escape.to_unicode(hashed_token)
+
+ user.api_token = hashed_token
+ session.commit()
+
+ self.set_status(200)
+ await self.finish(
+ {
+ "message": "API token generated successfully",
+ "api_token": new_token,
+ }
+ )
+
+
+@ApiRoute("auth/api-token/revoke", ApiVersion.V1)
+class ApiTokenRevokeController(BaseAPIController):
+ @api_authenticated
+ async def post(self):
+ """Revoke the API token for the authenticated user"""
+ with self.make_session() as session:
+ user = session.query(User).get(self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ if not user.api_token:
+ self.set_status(400)
+ await self.finish({"message": "No API token to revoke"})
+ return
+
+ user.api_token = None
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "API token revoked successfully"})
+
+
+@ApiRoute("auth/api-token", ApiVersion.V1)
+class ApiTokenController(BaseAPIController):
+ @api_authenticated
+ def get(self):
+ """Check if the authenticated user has an API token"""
+ with self.make_session() as session:
+ user = session.query(User).get(self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ self.finish({"message": "User not found"})
+ return
+
+ self.set_status(200)
+ self.finish({"has_token": user.api_token is not None})
diff --git a/server/models/user.py b/server/models/user.py
index 03727c879..f07b35446 100644
--- a/server/models/user.py
+++ b/server/models/user.py
@@ -18,6 +18,7 @@ class User(db.Model):
is_admin = Column(Boolean())
last_login = Column(DateTime())
last_seen = Column(DateTime())
+ api_token = Column(String(), nullable=True, index=True)
class UserSettings(db.Model):
diff --git a/server/pyproject.toml b/server/pyproject.toml
new file mode 100644
index 000000000..0163046ce
--- /dev/null
+++ b/server/pyproject.toml
@@ -0,0 +1,127 @@
+# ============================================================================
+# DigiScript Server - Python Project Configuration
+# ============================================================================
+# This file consolidates configuration for all Python development tools.
+# Dependencies are dynamically read from requirements.txt files.
+# ============================================================================
+
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "digiscript-server"
+version = "0.1.0"
+description = "DigiScript server - Digital script management for theatrical shows"
+readme = "../README.md"
+requires-python = ">=3.13"
+# Dependencies are dynamically loaded from requirements.txt
+dynamic = ["dependencies", "optional-dependencies"]
+
+[tool.setuptools.dynamic]
+dependencies = {file = ["requirements.txt"]}
+optional-dependencies.test = {file = ["test_requirements.txt"]}
+
+# ============================================================================
+# PYTEST CONFIGURATION
+# ============================================================================
+[tool.pytest.ini_options]
+# Add the server directory to Python path so imports work correctly
+# This allows running `pytest` directly instead of `python -m pytest`
+pythonpath = ["."]
+
+# Test discovery patterns
+testpaths = ["test"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+
+# Asyncio configuration for pytest-asyncio
+asyncio_mode = "auto"
+asyncio_default_fixture_loop_scope = "function"
+
+# Output options
+addopts = [
+ "-v", # Verbose output
+ "--tb=short", # Shorter traceback format
+ "--strict-markers", # Ensure all markers are defined
+]
+
+# Markers
+markers = [
+ "asyncio: mark test as async",
+]
+
+# ============================================================================
+# BLACK CONFIGURATION
+# ============================================================================
+[tool.black]
+# Use Black's default line-length of 88 to match existing codebase formatting
+line-length = 88
+target-version = ['py313']
+include = '\.pyi?$'
+extend-exclude = '''
+/(
+ # Exclude alembic migrations
+ | alembic_config/versions
+)/
+'''
+
+# ============================================================================
+# ISORT CONFIGURATION
+# ============================================================================
+[tool.isort]
+profile = "black"
+# Match Black's line length of 88
+line_length = 88
+skip_gitignore = true
+extend_skip = ["alembic_config/versions"]
+known_first_party = ["digi_server", "models", "controllers", "utils", "schemas", "rbac", "registry"]
+
+# ============================================================================
+# PYLINT CONFIGURATION
+# ============================================================================
+[tool.pylint.main]
+# Add current directory to path for imports
+init-hook = "from pylint.config import find_default_config_files; import sys; sys.path.append(next(find_default_config_files()).parent.as_posix())"
+
+# Ignore patterns
+ignore-paths = [
+ "^alembic_config/.*$",
+ "^test/.*$",
+]
+
+[tool.pylint."messages control"]
+disable = [
+ "logging-fstring-interpolation",
+ "missing-module-docstring",
+ "missing-class-docstring",
+ "missing-function-docstring",
+ "too-few-public-methods",
+ "duplicate-code",
+ "too-many-return-statements",
+ "too-many-branches",
+ "too-many-statements",
+ "unnecessary-lambda",
+ "too-many-locals",
+ "too-many-nested-blocks",
+ "too-many-arguments",
+ "unnecessary-dunder-call",
+ "broad-exception-raised",
+ "broad-exception-caught",
+ "fixme",
+]
+
+[tool.pylint.format]
+# Note: Pylint's max-line-length is 120 but Black enforces 88
+# This is intentional - Pylint checks logic, Black formats code
+max-line-length = 120
+
+[tool.pylint.design]
+max-args = 15
+max-locals = 20
+max-returns = 20
+max-branches = 20
+max-statements = 50
+max-attributes = 20
+max-positional-arguments = 15
diff --git a/server/requirements.txt b/server/requirements.txt
index 59f207c42..db42c3019 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -1,11 +1,12 @@
-tornado==6.5.1
+tornado==6.5.2
tornado-sqlalchemy==0.7.0
datetime==4.9
python-dateutil==2.9.0.post0
-marshmallow-sqlalchemy==0.30.0
+marshmallow-sqlalchemy>=1.4.0
tornado-prometheus==0.1.2
bcrypt==4.3.0
anytree==2.13.0
-alembic==1.16.2
-marshmallow<4
-pyjwt[crypto]==2.10.1
\ No newline at end of file
+alembic==1.17.2
+marshmallow<5
+pyjwt[crypto]==2.10.1
+setuptools==80.9.0
\ No newline at end of file
diff --git a/server/schemas/schemas.py b/server/schemas/schemas.py
index 9d6df4070..aac3af258 100644
--- a/server/schemas/schemas.py
+++ b/server/schemas/schemas.py
@@ -37,7 +37,7 @@ class Meta:
model = User
load_instance = True
include_fk = True
- exclude = ("password",)
+ exclude = ("password", "api_token")
@schema
diff --git a/server/test/test_auth_api.py b/server/test/test_auth_api.py
index 971d11c74..d5e7cfcf7 100644
--- a/server/test/test_auth_api.py
+++ b/server/test/test_auth_api.py
@@ -167,3 +167,189 @@ def test_jwt_logout_revoke(self):
"/api/v1/auth", method="GET", headers={"Authorization": f"Bearer {token}"}
)
self.assertEqual(401, response.code)
+
+ def test_api_token_generate(self):
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "foobar",
+ "password": "password",
+ "is_admin": True,
+ }
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "foobar", "password": "password"}),
+ )
+ response_body = escape.json_decode(response.body)
+ token = response_body["access_token"]
+
+ # Generate API token
+ response = self.fetch(
+ "/api/v1/auth/api-token/generate",
+ method="POST",
+ body=escape.json_encode({}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ response_body = escape.json_decode(response.body)
+ self.assertEqual(200, response.code)
+ self.assertTrue("api_token" in response_body)
+ self.assertTrue(len(response_body["api_token"]) > 0)
+
+ def test_api_token_authentication(self):
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "foobar",
+ "password": "password",
+ "is_admin": True,
+ }
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "foobar", "password": "password"}),
+ )
+ response_body = escape.json_decode(response.body)
+ token = response_body["access_token"]
+
+ # Generate API token
+ response = self.fetch(
+ "/api/v1/auth/api-token/generate",
+ method="POST",
+ body=escape.json_encode({}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ response_body = escape.json_decode(response.body)
+ api_token = response_body["api_token"]
+
+ # Use API token to authenticate
+ response = self.fetch(
+ "/api/v1/auth", method="GET", headers={"X-API-Key": api_token}
+ )
+ self.assertEqual(200, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertEqual("foobar", response_body["username"])
+
+ def test_api_token_invalid(self):
+ # Try to use an invalid API token
+ response = self.fetch(
+ "/api/v1/auth", method="GET", headers={"X-API-Key": "invalid-token"}
+ )
+ self.assertEqual(401, response.code)
+
+ def test_api_token_revoke(self):
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "foobar",
+ "password": "password",
+ "is_admin": True,
+ }
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "foobar", "password": "password"}),
+ )
+ response_body = escape.json_decode(response.body)
+ token = response_body["access_token"]
+
+ # Generate API token
+ response = self.fetch(
+ "/api/v1/auth/api-token/generate",
+ method="POST",
+ body=escape.json_encode({}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ response_body = escape.json_decode(response.body)
+ api_token = response_body["api_token"]
+
+ # Verify API token works
+ response = self.fetch(
+ "/api/v1/auth", method="GET", headers={"X-API-Key": api_token}
+ )
+ self.assertEqual(200, response.code)
+
+ # Revoke API token
+ response = self.fetch(
+ "/api/v1/auth/api-token/revoke",
+ method="POST",
+ body=escape.json_encode({}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify API token no longer works
+ response = self.fetch(
+ "/api/v1/auth", method="GET", headers={"X-API-Key": api_token}
+ )
+ self.assertEqual(401, response.code)
+
+ def test_api_token_get(self):
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "foobar",
+ "password": "password",
+ "is_admin": True,
+ }
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "foobar", "password": "password"}),
+ )
+ response_body = escape.json_decode(response.body)
+ token = response_body["access_token"]
+
+ # Check no token exists initially
+ response = self.fetch(
+ "/api/v1/auth/api-token",
+ method="GET",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ response_body = escape.json_decode(response.body)
+ self.assertEqual(200, response.code)
+ self.assertFalse(response_body["has_token"])
+
+ # Generate API token
+ response = self.fetch(
+ "/api/v1/auth/api-token/generate",
+ method="POST",
+ body=escape.json_encode({}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ response_body = escape.json_decode(response.body)
+ api_token = response_body["api_token"]
+
+ # Check that token exists (but can't retrieve it)
+ response = self.fetch(
+ "/api/v1/auth/api-token",
+ method="GET",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ response_body = escape.json_decode(response.body)
+ self.assertEqual(200, response.code)
+ self.assertTrue(response_body["has_token"])
diff --git a/server/test/test_utils.py b/server/test/test_utils.py
index f5ec36dfc..f2f5d9427 100644
--- a/server/test/test_utils.py
+++ b/server/test/test_utils.py
@@ -39,4 +39,8 @@ def tearDown(self):
table_inspect = inspect(table)
models.db.metadata.remove(table_inspect.mapped_table)
+ # Dispose the database engine to ensure proper test isolation
+ # This forces a new in-memory database for each test
+ models.db.engine.dispose()
+
super().tearDown()
diff --git a/server/test_requirements.txt b/server/test_requirements.txt
index 2d40e68a9..33bd85b8c 100644
--- a/server/test_requirements.txt
+++ b/server/test_requirements.txt
@@ -1,5 +1,5 @@
pytest<8.5
pytest-asyncio
-pylint==3.3.7
-black==25.1.0
+pylint==3.3.9
+black==25.11.0
isort==5.13.2
\ No newline at end of file
diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py
index 2c7570718..77b8378ac 100644
--- a/server/utils/web/base_controller.py
+++ b/server/utils/web/base_controller.py
@@ -2,7 +2,9 @@
from typing import TYPE_CHECKING, Any, Awaitable, Optional
+import bcrypt
from tornado import escape, httputil
+from tornado.ioloop import IOLoop
from tornado.web import HTTPError, RequestHandler
from tornado_sqlalchemy import SessionMixin
@@ -36,14 +38,13 @@ async def prepare(
show_schema = ShowSchema()
user_schema = UserSchema()
- # Extract JWT token from header
- auth_header = self.request.headers.get("Authorization", "")
- token = self.application.jwt_service.get_token_from_authorization_header(
- auth_header
- )
-
with self.make_session() as session:
- # If we have a token, try to authenticate with it
+ # First, try JWT authentication
+ auth_header = self.request.headers.get("Authorization", "")
+ token = self.application.jwt_service.get_token_from_authorization_header(
+ auth_header
+ )
+
if token:
is_revoked = await self.application.jwt_service.is_token_revoked(token)
if is_revoked:
@@ -55,6 +56,33 @@ async def prepare(
if user:
self.current_user = user_schema.dump(user)
+ # If not authenticated via JWT, try API token authentication
+ if not self.current_user:
+ api_key = self.request.headers.get("X-API-Key", "")
+ if api_key:
+ # Get all users with API tokens and check each one
+ users_with_tokens = (
+ session.query(User).filter(User.api_token.isnot(None)).all()
+ )
+
+ authenticated_user = None
+ for user in users_with_tokens:
+ # Compare the provided token with the hashed token
+ token_matches = await IOLoop.current().run_in_executor(
+ None,
+ bcrypt.checkpw,
+ escape.utf8(api_key),
+ escape.utf8(user.api_token),
+ )
+ if token_matches:
+ authenticated_user = user
+ break
+
+ if authenticated_user:
+ self.current_user = user_schema.dump(authenticated_user)
+ else:
+ raise HTTPError(401, log_message="Invalid API key")
+
current_show = await self.application.digi_settings.get("current_show")
if current_show:
show = session.query(Show).get(current_show)