From 55094925639189b2e200b6f8f567f731bfe4547c Mon Sep 17 00:00:00 2001
From: shuaiplus <2327005759@qq.com>
Date: Sat, 28 Feb 2026 01:02:34 +0800
Subject: [PATCH] feat: add cryptographic utilities and types for secure data
handling
---
.gitignore | 3 +
package-lock.json | 1638 +++++++++++++++++++++
package.json | 14 +-
public/index.html | 21 +-
public/web/app.js | 2 -
public/web/crypto.js | 150 --
public/web/i18n.js | 216 ---
public/web/main.js | 1522 -------------------
public/web/runtime-config.js | 27 -
public/web/styles.css | 812 ----------
public/web/vault-utils.js | 44 -
public/web/vendor/qrcode-generator.min.js | 1 -
webapp/index.html | 12 +
webapp/src/App.tsx | 756 ++++++++++
webapp/src/components/AdminPage.tsx | 116 ++
webapp/src/components/AuthViews.tsx | 173 +++
webapp/src/components/ConfirmDialog.tsx | 37 +
webapp/src/components/HelpPage.tsx | 23 +
webapp/src/components/SettingsPage.tsx | 128 ++
webapp/src/components/ToastHost.tsx | 23 +
webapp/src/components/VaultPage.tsx | 1045 +++++++++++++
webapp/src/lib/api.ts | 597 ++++++++
webapp/src/lib/crypto.ts | 174 +++
webapp/src/lib/types.ts | 222 +++
webapp/src/main.tsx | 20 +
webapp/src/styles.css | 700 +++++++++
webapp/src/vite-env.d.ts | 10 +
webapp/tsconfig.json | 22 +
webapp/vite.config.ts | 35 +
29 files changed, 5757 insertions(+), 2786 deletions(-)
delete mode 100644 public/web/app.js
delete mode 100644 public/web/crypto.js
delete mode 100644 public/web/i18n.js
delete mode 100644 public/web/main.js
delete mode 100644 public/web/runtime-config.js
delete mode 100644 public/web/styles.css
delete mode 100644 public/web/vault-utils.js
delete mode 100644 public/web/vendor/qrcode-generator.min.js
create mode 100644 webapp/index.html
create mode 100644 webapp/src/App.tsx
create mode 100644 webapp/src/components/AdminPage.tsx
create mode 100644 webapp/src/components/AuthViews.tsx
create mode 100644 webapp/src/components/ConfirmDialog.tsx
create mode 100644 webapp/src/components/HelpPage.tsx
create mode 100644 webapp/src/components/SettingsPage.tsx
create mode 100644 webapp/src/components/ToastHost.tsx
create mode 100644 webapp/src/components/VaultPage.tsx
create mode 100644 webapp/src/lib/api.ts
create mode 100644 webapp/src/lib/crypto.ts
create mode 100644 webapp/src/lib/types.ts
create mode 100644 webapp/src/main.tsx
create mode 100644 webapp/src/styles.css
create mode 100644 webapp/src/vite-env.d.ts
create mode 100644 webapp/tsconfig.json
create mode 100644 webapp/vite.config.ts
diff --git a/.gitignore b/.gitignore
index 9cd77ce..f18e0e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ tests/selfcheck.ts
# Build output
dist/
build/
+public/
+public2/
+
# IDE
.vscode/
diff --git a/package-lock.json b/package-lock.json
index 816bc3e..82ea3ce 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,14 +8,369 @@
"name": "nodewarden",
"version": "1.1.0",
"license": "LGPL-3.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.90.21",
+ "preact": "^10.28.4",
+ "qrcode-generator": "^2.0.4",
+ "wouter": "^3.9.0"
+ },
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
+ "@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
+ "vite": "^7.3.1",
"wrangler": "^4.61.1"
}
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/generator/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.27.3",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+ "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
+ "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-syntax-jsx": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
+ "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@cloudflare/kv-asset-handler": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz",
@@ -1091,6 +1446,50 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmmirror.com/@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/remapping/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1148,6 +1547,469 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@preact/preset-vite": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmmirror.com/@preact/preset-vite/-/preset-vite-2.10.3.tgz",
+ "integrity": "sha512-1SiS+vFItpkNdBs7q585PSAIln0wBeBdcpJYbzPs1qipsb/FssnkUioNXuRsb8ZnU8YEQHr+3v8+/mzWSnTQmg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-development": "^7.27.1",
+ "@prefresh/vite": "^2.4.11",
+ "@rollup/pluginutils": "^5.0.0",
+ "babel-plugin-transform-hook-names": "^1.0.2",
+ "debug": "^4.4.3",
+ "picocolors": "^1.1.1",
+ "vite-prerender-plugin": "^0.5.8"
+ },
+ "peerDependencies": {
+ "@babel/core": "7.x",
+ "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x"
+ }
+ },
+ "node_modules/@prefresh/babel-plugin": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmmirror.com/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz",
+ "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@prefresh/core": {
+ "version": "1.5.9",
+ "resolved": "https://registry.npmmirror.com/@prefresh/core/-/core-1.5.9.tgz",
+ "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": "^10.0.0 || ^11.0.0-0"
+ }
+ },
+ "node_modules/@prefresh/utils": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@prefresh/utils/-/utils-1.2.1.tgz",
+ "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@prefresh/vite": {
+ "version": "2.4.12",
+ "resolved": "https://registry.npmmirror.com/@prefresh/vite/-/vite-2.4.12.tgz",
+ "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.22.1",
+ "@prefresh/babel-plugin": "^0.5.2",
+ "@prefresh/core": "^1.5.0",
+ "@prefresh/utils": "^1.2.0",
+ "@rollup/pluginutils": "^4.2.1"
+ },
+ "peerDependencies": {
+ "preact": "^10.4.0 || ^11.0.0-0",
+ "vite": ">=2.0.0"
+ }
+ },
+ "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
+ "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "estree-walker": "^2.0.1",
+ "picomatch": "^2.2.2"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/@prefresh/vite/node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/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/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@sindresorhus/is": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-7.2.0.tgz",
@@ -1168,16 +2030,73 @@
"dev": true,
"license": "CC0-1.0"
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.20",
+ "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.20.tgz",
+ "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.21",
+ "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.21.tgz",
+ "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
+ "node_modules/babel-plugin-transform-hook-names": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
+ "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@babel/core": "^7.12.10"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/blake3-wasm": {
"version": "2.1.5",
"resolved": "https://registry.npmmirror.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
@@ -1185,6 +2104,76 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001774",
+ "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
+ "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
@@ -1199,6 +2188,54 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1209,6 +2246,85 @@
"node": ">=8"
}
},
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.302",
+ "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
+ "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/error-stack-parser-es": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
@@ -1261,6 +2377,41 @@
"@esbuild/win32-x64": "0.27.0"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/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/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -1276,6 +2427,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -1289,6 +2450,49 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz",
@@ -1299,6 +2503,33 @@
"node": ">=6"
}
},
+ "node_modules/kolorist": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz",
+ "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/miniflare": {
"version": "4.20260128.0",
"resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260128.0.tgz",
@@ -1320,6 +2551,69 @@
"node": ">=18.0.0"
}
},
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-html-parser": {
+ "version": "6.1.13",
+ "resolved": "https://registry.npmmirror.com/node-html-parser/-/node-html-parser-6.1.13.tgz",
+ "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "he": "1.2.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "6.3.0",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
@@ -1334,6 +2628,91 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.28.4",
+ "resolved": "https://registry.npmmirror.com/preact/-/preact-10.28.4.tgz",
+ "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/qrcode-generator": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
+ "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/regexparam": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
+ "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -1344,6 +2723,52 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
@@ -1402,6 +2827,46 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
+ "node_modules/simple-code-frame": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/simple-code-frame/-/simple-code-frame-1.3.0.tgz",
+ "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kolorist": "^1.6.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stack-trace": {
+ "version": "1.0.0-pre2",
+ "resolved": "https://registry.npmmirror.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz",
+ "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz",
@@ -1415,6 +2880,23 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
@@ -1429,6 +2911,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -1485,6 +2968,140 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-prerender-plugin": {
+ "version": "0.5.12",
+ "resolved": "https://registry.npmmirror.com/vite-prerender-plugin/-/vite-prerender-plugin-0.5.12.tgz",
+ "integrity": "sha512-EiwhbMn+flg14EysbLTmZSzq8NGTxhytgK3bf4aGRF1evWLGwZiHiUJ1KZDvbxgKbMf2pG6fJWGEa3UZXOnR1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kolorist": "^1.8.0",
+ "magic-string": "0.x >= 0.26.0",
+ "node-html-parser": "^6.1.12",
+ "simple-code-frame": "^1.3.0",
+ "source-map": "^0.7.4",
+ "stack-trace": "^1.0.0-pre2"
+ },
+ "peerDependencies": {
+ "vite": "5.x || 6.x || 7.x"
+ }
+ },
"node_modules/workerd": {
"version": "1.20260128.0",
"resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260128.0.tgz",
@@ -1507,6 +3124,20 @@
"@cloudflare/workerd-windows-64": "1.20260128.0"
}
},
+ "node_modules/wouter": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmmirror.com/wouter/-/wouter-3.9.0.tgz",
+ "integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==",
+ "license": "Unlicense",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "regexparam": "^3.0.0",
+ "use-sync-external-store": "^1.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/wrangler": {
"version": "4.61.1",
"resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.61.1.tgz",
@@ -1564,6 +3195,13 @@
}
}
},
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/youch": {
"version": "4.1.0-beta.10",
"resolved": "https://registry.npmmirror.com/youch/-/youch-4.1.0-beta.10.tgz",
diff --git a/package.json b/package.json
index 213095a..f36b490 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,11 @@
"main": "src/index.ts",
"type": "module",
"scripts": {
- "dev": "wrangler dev -c wrangler.toml",
+ "dev": "npm run web:build && wrangler dev -c wrangler.toml",
+ "dev:worker": "wrangler dev -c wrangler.toml",
+ "web:dev": "vite --config webapp/vite.config.ts",
+ "web:build": "vite build --config webapp/vite.config.ts",
+ "web:typecheck": "tsc -p webapp/tsconfig.json --noEmit",
"deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy"
},
@@ -33,9 +37,17 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
+ "@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
+ "vite": "^7.3.1",
"wrangler": "^4.61.1"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.90.21",
+ "preact": "^10.28.4",
+ "qrcode-generator": "^2.0.4",
+ "wouter": "^3.9.0"
}
}
diff --git a/public/index.html b/public/index.html
index 7b1ecd6..be3c367 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,14 +1,13 @@
-
-
-
- NodeWarden Web
-
-
-
-
-
-
-
+
+
+
+ NodeWarden
+
+
+
+
+
+
diff --git a/public/web/app.js b/public/web/app.js
deleted file mode 100644
index 1dad3d1..0000000
--- a/public/web/app.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export { startNodewardenApp } from './main.js';
-
diff --git a/public/web/crypto.js b/public/web/crypto.js
deleted file mode 100644
index 88130e7..0000000
--- a/public/web/crypto.js
+++ /dev/null
@@ -1,150 +0,0 @@
-export function bytesToBase64(bytes) {
- var s = '';
- for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
- return btoa(s);
-}
-
-export function base64ToBytes(b64) {
- var bin = atob(b64);
- var bytes = new Uint8Array(bin.length);
- for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
- return bytes;
-}
-
-export function concatBytes(a, b) {
- var o = new Uint8Array(a.length + b.length);
- o.set(a, 0);
- o.set(b, a.length);
- return o;
-}
-
-export async function pbkdf2(passwordOrBytes, saltOrBytes, iterations, keyLen) {
- var pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
- var saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
- var key = await crypto.subtle.importKey('raw', pwdBytes, 'PBKDF2', false, ['deriveBits']);
- var bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: saltBytes, iterations: iterations }, key, keyLen * 8);
- return new Uint8Array(bits);
-}
-
-export async function hkdfExpand(prk, info, length) {
- var enc = new TextEncoder();
- var key = await crypto.subtle.importKey('raw', prk, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
- var infoBytes = enc.encode(info || '');
- var result = new Uint8Array(length);
- var prev = new Uint8Array(0);
- var off = 0;
- var cnt = 1;
- while (off < length) {
- var inp = new Uint8Array(prev.length + infoBytes.length + 1);
- inp.set(prev, 0);
- inp.set(infoBytes, prev.length);
- inp[inp.length - 1] = cnt & 0xff;
- prev = new Uint8Array(await crypto.subtle.sign('HMAC', key, inp));
- var c = Math.min(prev.length, length - off);
- result.set(prev.slice(0, c), off);
- off += c;
- cnt += 1;
- }
- return result;
-}
-
-export async function hmacSha256(keyBytes, dataBytes) {
- var key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
- return new Uint8Array(await crypto.subtle.sign('HMAC', key, dataBytes));
-}
-
-export async function encryptAesCbc(data, key, iv) {
- var ck = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['encrypt']);
- return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: iv }, ck, data));
-}
-
-export async function decryptAesCbc(data, key, iv) {
- var ck = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['decrypt']);
- return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: iv }, ck, data));
-}
-
-export async function encryptBw(data, encKey, macKey) {
- var iv = crypto.getRandomValues(new Uint8Array(16));
- var cipher = await encryptAesCbc(data, encKey, iv);
- var mac = await hmacSha256(macKey, concatBytes(iv, cipher));
- return '2.' + bytesToBase64(iv) + '|' + bytesToBase64(cipher) + '|' + bytesToBase64(mac);
-}
-
-export function parseCipherString(s) {
- if (!s || typeof s !== 'string') throw new Error('invalid encrypted string');
- if (s === 'null' || s === 'undefined') throw new Error('invalid encrypted string');
- var p = s.indexOf('.');
- if (p <= 0) throw new Error('invalid encrypted string');
- var type = Number(s.slice(0, p));
- var body = s.slice(p + 1);
- var parts = body.split('|');
- if (type === 2 && parts.length === 3) return { type: 2, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: base64ToBytes(parts[2]) };
- if ((type === 0 || type === 1 || type === 4) && parts.length >= 2) return { type: type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null };
- throw new Error('unsupported enc type or format');
-}
-
-export async function decryptBw(cipherString, encKey, macKey) {
- var parsed = parseCipherString(cipherString);
- if (parsed.type === 2 && macKey && parsed.mac) {
- var expect = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct));
- if (bytesToBase64(expect) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch');
- }
- return decryptAesCbc(parsed.ct, encKey, parsed.iv);
-}
-
-export async function decryptStr(cipherString, encKey, macKey) {
- if (!cipherString || typeof cipherString !== 'string') return '';
- var plain = await decryptBw(cipherString, encKey, macKey);
- return new TextDecoder().decode(plain);
-}
-
-export function extractTotpSecret(raw) {
- if (!raw) return '';
- var s = String(raw).trim();
- if (!s) return '';
- if (/^otpauth:\/\//i.test(s)) {
- try {
- var u = new URL(s);
- var qp = u.searchParams.get('secret') || '';
- return qp.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
- } catch (_) {}
- }
- return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
-}
-
-export function base32ToBytes(input) {
- var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
- var clean = String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
- var bits = 0, value = 0, out = [];
- for (var i = 0; i < clean.length; i++) {
- var idx = alphabet.indexOf(clean.charAt(i));
- if (idx < 0) continue;
- value = (value << 5) | idx;
- bits += 5;
- if (bits >= 8) {
- out.push((value >>> (bits - 8)) & 0xff);
- bits -= 8;
- }
- }
- return new Uint8Array(out);
-}
-
-export async function calcTotpNow(rawSecret) {
- var secret = extractTotpSecret(rawSecret);
- if (!secret) return null;
- var keyBytes = base32ToBytes(secret);
- if (!keyBytes.length) return null;
- var step = 30;
- var epoch = Math.floor(Date.now() / 1000);
- var counter = Math.floor(epoch / step);
- var remain = step - (epoch % step);
- var msg = new Uint8Array(8);
- var c = counter;
- for (var i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); }
- var key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
- var hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
- var off = hs[hs.length - 1] & 0x0f;
- var bin = ((hs[off] & 0x7f) << 24) | ((hs[off + 1] & 0xff) << 16) | ((hs[off + 2] & 0xff) << 8) | (hs[off + 3] & 0xff);
- var code = (bin % 1000000).toString().padStart(6, '0');
- return { code: code, remain: remain };
-}
diff --git a/public/web/i18n.js b/public/web/i18n.js
deleted file mode 100644
index 61e2e14..0000000
--- a/public/web/i18n.js
+++ /dev/null
@@ -1,216 +0,0 @@
-export const I18N = {
- en: {
- brand: 'NodeWarden',
- subtitle: 'Open Source Password Manager',
- login: 'Log In',
- register: 'Create Account',
- email: 'Email Address',
- masterPwd: 'Master Password',
- confirmPwd: 'Confirm Master Password',
- name: 'Name',
- inviteCode: 'Invite Code (Optional)',
- loginBtn: 'Log In',
- registerBtn: 'Create Account',
- backToLogin: 'Back to Log In',
- vault: 'Vault',
- settings: 'Settings',
- admin: 'Admin',
- help: 'Help',
- logout: 'Log Out',
- folders: 'Folders',
- allItems: 'All Items',
- noFolder: 'No Folder',
- searchVault: 'Search vault',
- filter: 'Search',
- typeAll: 'All items',
- typeLogin: 'Logins',
- typeCard: 'Cards',
- typeIdentity: 'Identities',
- typeNote: 'Secure notes',
- typeOther: 'Other',
- addWebsite: '+ Add website',
- addField: '+ Add field',
- fieldType: 'Field type',
- fieldLabel: 'Field label',
- fieldValue: 'Field value',
- fieldText: 'Text',
- fieldHidden: 'Hidden',
- fieldBoolean: 'Boolean',
- fieldLinked: 'Linked',
- add: 'Add',
- newTypeLogin: 'Login',
- newTypeCard: 'Card',
- newTypeIdentity: 'Identity',
- newTypeNote: 'Note',
- newTypeSsh: 'SSH key',
- refresh: 'Sync',
- move: 'Move',
- delete: 'Delete',
- selectAll: 'Select All',
- clear: 'Cancel',
- noItems: 'There are no items to list.',
- selectItem: 'Select an item to view details.',
- profile: 'Profile',
- saveProfile: 'Save Profile',
- changePwd: 'Change Master Password',
- currentPwd: 'Current Master Password',
- newPwd: 'New Master Password',
- totpSetup: 'Two-Step Login (TOTP)',
- totpLiveIn: 'Refresh in',
- enableTotp: 'Enable TOTP',
- disableTotp: 'Disable TOTP',
- secret: 'Authenticator Key',
- verifyCode: 'Verification Code',
- credentials: 'Login credentials',
- autofillOptions: 'Autofill',
- itemHistory: 'Item history',
- website: 'Website',
- folder: 'Folder',
- createdAt: 'Created',
- updatedAt: 'Last edited',
- open: 'Open',
- copy: 'Copy',
- reveal: 'Reveal',
- hide: 'Hide',
- users: 'Users',
- invites: 'Invites',
- createInvite: 'Create Invite',
- expiresIn: 'Expires in (hours)',
- copyLink: 'Copy Link',
- revoke: 'Revoke',
- ban: 'Ban',
- unban: 'Unban',
- status: 'Status',
- role: 'Role',
- action: 'Options',
- loading: 'Loading NodeWarden...',
- totpVerify: 'Two-step verification',
- totpVerifySub: 'Password is already verified.',
- totpCode: 'TOTP Code',
- verify: 'Verify',
- cancel: 'Cancel',
- totpDisableSub: 'Enter master password to disable two-step verification.',
- helpSync: 'Upstream Sync',
- helpSync1: 'Track upstream with a fork and scheduled sync workflow (recommended).',
- helpSync2: 'Before merge: compare API routes, migration files, and auth logic changes.',
- helpSync3: 'After merge: run local dev migration tests, then deploy Worker after validation.',
- helpErr: 'Common Errors',
- helpErr1: '401 Unauthorized: token expired or revoked, login again.',
- helpErr2: '403 Account disabled: admin must unban user in User Management.',
- helpErr3: '403 Invite invalid: invite expired/used/revoked, create a new invite.',
- helpErr4: '429 Too many requests: wait retry seconds and avoid burst writes.',
- helpTb: 'Troubleshooting',
- helpTb1: 'Login OK but encrypted values shown: verify profile key and KDF settings are consistent.',
- helpTb2: 'TOTP fails repeatedly: sync device time and re-scan QR using latest secret.',
- helpTb3: 'Password change failed: ensure current password is correct and new password has at least 12 chars.',
- helpTb4: 'Sync conflicts: refresh vault and retry one operation at a time.',
- langSwitch: '中文',
- },
- zh: {
- brand: 'NodeWarden',
- subtitle: '开源密码管理器',
- login: '登录',
- register: '创建账号',
- email: '电子邮件地址',
- masterPwd: '主密码',
- confirmPwd: '确认主密码',
- name: '姓名',
- inviteCode: '邀请码 (可选)',
- loginBtn: '登录',
- registerBtn: '创建账号',
- backToLogin: '返回登录',
- vault: '密码库',
- settings: '设置',
- admin: '管理',
- help: '帮助',
- logout: '退出登录',
- folders: '文件夹',
- allItems: '所有项目',
- noFolder: '无文件夹',
- searchVault: '搜索密码库',
- filter: '筛选',
- typeAll: '所有项目',
- typeLogin: '登录',
- typeCard: '支付卡',
- typeIdentity: '身份',
- typeNote: '备注',
- typeOther: '其他',
- addWebsite: '+ 添加网站',
- addField: '+ 添加字段',
- fieldType: '字段类型',
- fieldLabel: '字段标签',
- fieldValue: '字段值',
- fieldText: '文本型',
- fieldHidden: '隐藏型',
- fieldBoolean: '复选框型',
- fieldLinked: '链接型',
- add: '添加',
- newTypeLogin: '登录',
- newTypeCard: '支付卡',
- newTypeIdentity: '身份',
- newTypeNote: '笔记',
- newTypeSsh: 'SSH 密钥',
- refresh: '同步',
- move: '移动',
- delete: '删除',
- selectAll: '全选',
- clear: '取消',
- noItems: '没有可列出的项目。',
- selectItem: '选择一个项目以查看详细信息。',
- profile: '个人资料',
- saveProfile: '保存个人资料',
- changePwd: '更改主密码',
- currentPwd: '当前主密码',
- newPwd: '新主密码',
- totpSetup: '两步登录 (TOTP)',
- totpLiveIn: '刷新剩余',
- enableTotp: '启用 TOTP',
- disableTotp: '禁用 TOTP',
- secret: '身份验证器密钥',
- verifyCode: '验证码',
- credentials: '登录凭据',
- autofillOptions: '自动填充',
- itemHistory: '项目历史记录',
- website: '网站',
- folder: '文件夹',
- createdAt: '创建于',
- updatedAt: '最后编辑',
- open: '打开',
- copy: '复制',
- reveal: '显示',
- hide: '隐藏',
- users: '用户',
- invites: '邀请',
- createInvite: '创建邀请',
- expiresIn: '过期时间 (小时)',
- copyLink: '复制链接',
- revoke: '撤销',
- ban: '封禁',
- unban: '解封',
- status: '状态',
- role: '角色',
- action: '选项',
- loading: '正在加载 NodeWarden...',
- totpVerify: '两步验证',
- totpVerifySub: '密码已验证。',
- totpCode: 'TOTP 验证码',
- verify: '验证',
- cancel: '取消',
- totpDisableSub: '输入主密码以禁用两步验证。',
- helpSync: '上游同步',
- helpSync1: '建议通过 fork 和定时同步工作流跟踪上游。',
- helpSync2: '合并前:比较 API 路由、迁移文件和认证逻辑的更改。',
- helpSync3: '合并后:运行本地开发迁移测试,验证后部署 Worker。',
- helpErr: '常见错误',
- helpErr1: '401 未授权:令牌过期或被撤销,请重新登录。',
- helpErr2: '403 账号被禁用:管理员必须在用户管理中解封用户。',
- helpErr3: '403 邀请无效:邀请已过期/已使用/被撤销,请创建新邀请。',
- helpErr4: '429 请求过多:等待重试时间,避免突发写入。',
- helpTb: '排障指南',
- helpTb1: '登录成功但显示密文:检查 profile key 和 KDF 参数是否一致。',
- helpTb2: 'TOTP 持续失败:同步设备时间并使用最新密钥重新扫码。',
- helpTb3: '修改密码失败:确认当前密码正确且新密码至少 12 位。',
- helpTb4: '同步冲突:先刷新密码库,再逐个操作重试。',
- langSwitch: 'English',
- },
-};
diff --git a/public/web/main.js b/public/web/main.js
deleted file mode 100644
index 63462d1..0000000
--- a/public/web/main.js
+++ /dev/null
@@ -1,1522 +0,0 @@
-import { I18N } from './i18n.js';
-import { bytesToBase64, base64ToBytes, concatBytes, pbkdf2, hkdfExpand, encryptBw, decryptBw, decryptStr, extractTotpSecret, calcTotpNow } from './crypto.js';
-import { parseFieldType as parseFieldTypeUtil, selectedCount as selectedCountUtil, cipherTypeKey as cipherTypeKeyUtil, firstCipherUri as firstCipherUriUtil, hostFromUri as hostFromUriUtil } from './vault-utils.js';
-
-export function startNodewardenApp(runtimeConfig) {
- var app = document.getElementById('app');
- var defaultKdfIterations = Number(runtimeConfig && runtimeConfig.defaultKdfIterations) || 600000;
- var state = {
- phase: 'loading',
- lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en',
- msg: '',
- msgType: 'ok',
- inviteCode: '',
- registerName: '',
- registerEmail: '',
- registerPassword: '',
- registerPassword2: '',
- registerShowPassword: false,
- registerShowPassword2: false,
- session: null,
- profile: null,
- tab: 'vault',
- ciphers: [],
- folders: [],
- folderFilterId: '',
- vaultQuery: '',
- vaultType: 'all',
- showSelectedPassword: false,
- vaultSearchComposing: false,
- vaultSearchTimer: 0,
- totpTicking: false,
- totpTickBusy: false,
- detailMode: 'view',
- detailDraft: null,
- createMenuOpen: false,
- fieldModalOpen: false,
- fieldModalType: 'text',
- fieldModalLabel: '',
- fieldModalValue: '',
- selectedCipherId: '',
- selectedMap: {},
- users: [],
- invites: [],
- loginEmail: '',
- loginPassword: '',
- loginShowPassword: false,
- loginTotpToken: '',
- loginTotpError: '',
- pendingLogin: null,
- totpSetupSecret: '',
- totpSetupToken: '',
- totpDisableOpen: false,
- totpDisablePassword: '',
- totpDisableError: '',
- unlockPassword: '',
- unlockError: '',
- unlockShowPassword: false,
- lockTimeoutMinutes: 15,
- lockLastActiveTs: Date.now(),
- lockCheckTimer: 0,
- lockChannel: null,
- toasts: [],
- toastSeq: 0,
- dialog: null
- };
- var NO_FOLDER_FILTER = '__none__';
- var i18n = I18N;
-
- function t(key) { return i18n[state.lang][key] || key; }
-
- function esc(v) {
- return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
- }
- function sessionKey() { return 'nodewarden.web.session.v2'; }
- function lockSettingsKey() { return 'nodewarden.web.lock.v1'; }
- function dismissToast(id) {
- var next = [];
- for (var i = 0; i < state.toasts.length; i++) if (state.toasts[i].id !== id) next.push(state.toasts[i]);
- if (next.length === state.toasts.length) return;
- state.toasts = next;
- render();
- }
- function setMsg(t, ty) {
- var text = String(t || '').trim();
- if (!text) return;
- var id = 'toast-' + (++state.toastSeq);
- var level = ty === 'err' ? 'error' : (ty === 'warn' ? 'warning' : 'success');
- state.toasts.push({ id: id, text: text, level: level });
- if (state.toasts.length > 4) state.toasts = state.toasts.slice(state.toasts.length - 4);
- render();
- setTimeout(function () { dismissToast(id); }, 4500);
- }
- function clearMsg() {}
- function renderMsg() { return ''; }
- function renderToasts() {
- if (!state.toasts || state.toasts.length === 0) return '';
- var items = '';
- for (var i = 0; i < state.toasts.length; i++) {
- var x = state.toasts[i];
- items += ''
- + '' + esc(x.text) + '
'
- + '✕ '
- + '
'
- + ' ';
- }
- return '';
- }
- function askConfirm(opts) {
- return new Promise(function (resolve) {
- state.dialog = {
- type: 'confirm',
- title: String(opts && opts.title || 'Confirm'),
- message: String(opts && opts.message || ''),
- okText: String(opts && opts.okText || 'Yes'),
- cancelText: String(opts && opts.cancelText || 'No'),
- danger: !!(opts && opts.danger),
- resolve: resolve
- };
- render();
- });
- }
- function askMoveFolder() {
- return new Promise(function (resolve) {
- state.dialog = {
- type: 'move',
- title: 'Move selected items',
- message: 'Choose destination folder.',
- selectedFolderId: '__none__',
- resolve: resolve
- };
- render();
- });
- }
- function closeDialog(result) {
- var d = state.dialog;
- state.dialog = null;
- render();
- if (d && typeof d.resolve === 'function') d.resolve(result);
- }
- function renderDialog() {
- var d = state.dialog;
- if (!d) return '';
- if (d.type === 'move') {
- var options = '' + t('noFolder') + ' ';
- for (var i = 0; i < state.folders.length; i++) {
- var f = state.folders[i];
- var id = String(f.id || '');
- options += '' + esc(f.decName || f.name || id) + ' ';
- }
- return '⚠
' + esc(d.title) + ' ' + esc(d.message) + '
' + options + '
Move Cancel ';
- }
- return '⚠
' + esc(d.title) + ' ' + esc(d.message) + '
' + esc(d.okText) + ' ' + esc(d.cancelText) + ' ';
- }
- function saveSession() {
- if (!state.session) { localStorage.removeItem(sessionKey()); return; }
- var persisted = {
- accessToken: state.session.accessToken || '',
- refreshToken: state.session.refreshToken || '',
- email: state.session.email || ''
- };
- localStorage.setItem(sessionKey(), JSON.stringify(persisted));
- }
- function loadSession() {
- try {
- var r = localStorage.getItem(sessionKey());
- if (!r) return null;
- var p = JSON.parse(r);
- if (!p || !p.accessToken || !p.refreshToken) return null;
- return { accessToken: p.accessToken, refreshToken: p.refreshToken, email: p.email || '' };
- } catch (e) { return null; }
- }
- function saveLockSettings() {
- localStorage.setItem(lockSettingsKey(), JSON.stringify({ lockTimeoutMinutes: Number(state.lockTimeoutMinutes) || 0 }));
- }
- function loadLockSettings() {
- try {
- var r = localStorage.getItem(lockSettingsKey());
- if (!r) return;
- var p = JSON.parse(r);
- var mins = Number(p && p.lockTimeoutMinutes);
- if (Number.isFinite(mins) && mins >= 0) state.lockTimeoutMinutes = mins;
- } catch (_) {}
- }
- async function jsonOrNull(resp){ var t=await resp.text(); if(!t) return null; try{ return JSON.parse(t);} catch(e){ return null; } }
- async function decryptVault(){
- if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return;
- var encKey=base64ToBytes(state.session.symEncKey); var macKey=base64ToBytes(state.session.symMacKey);
- for(var i=0;i= mins * 60 * 1000) {
- lockVault(true, true);
- }
- }, 5000);
- }
-
- function lockVault(showMsg, broadcast) {
- if (state.session) {
- delete state.session.symEncKey;
- delete state.session.symMacKey;
- }
- clearVaultMemory();
- state.pendingLogin = null;
- state.loginTotpToken = '';
- state.loginTotpError = '';
- state.unlockPassword = '';
- state.unlockError = '';
- state.unlockShowPassword = false;
- state.phase = 'locked';
- if (broadcast !== false && state.lockChannel) {
- try { state.lockChannel.postMessage({ type: 'lock', at: Date.now() }); } catch (_) {}
- }
- if (showMsg) setMsg('Vault locked.', 'ok');
- else render();
- }
-
- async function onUnlock(form) {
- clearMsg();
- state.unlockError = '';
- var fd = new FormData(form);
- state.unlockPassword = String(fd.get('password') || '');
- if (!state.unlockPassword) {
- state.unlockError = 'Please input master password.';
- render();
- return;
- }
- try {
- var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : '').toLowerCase();
- if (!email) throw new Error('email missing');
- var d = await deriveLoginHash(email, state.unlockPassword);
- var ek = await hkdfExpand(d.masterKey, 'enc', 32);
- var em = await hkdfExpand(d.masterKey, 'mac', 32);
- var symKeyBytes = await decryptBw(state.profile.key, ek, em);
- if (!symKeyBytes || symKeyBytes.length < 64) throw new Error('invalid key');
- state.session.symEncKey = bytesToBase64(symKeyBytes.slice(0, 32));
- state.session.symMacKey = bytesToBase64(symKeyBytes.slice(32, 64));
- state.unlockPassword = '';
- state.unlockError = '';
- state.unlockShowPassword = false;
- await loadVault();
- await loadAdminData();
- state.phase = 'app';
- state.tab = 'vault';
- state.lockLastActiveTs = Date.now();
- render();
- setMsg('Unlocked.', 'ok');
- } catch (e) {
- state.unlockError = 'Unlock failed. Master password is incorrect.';
- render();
- }
- }
-
- function logout(){
- state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.unlockShowPassword=false; state.phase='login'; saveSession(); clearMsg(); render();
- }
-
- async function authFetch(path, options){
- var opts=options||{}; if(!state.session||!state.session.accessToken) throw new Error('unauthorized');
- var h=opts.headers?Object.assign({},opts.headers):{}; h.Authorization='Bearer '+state.session.accessToken;
- var r=await fetch(path,Object.assign({},opts,{headers:h})); if(r.status!==401) return r; if(!state.session.refreshToken) return r;
- var f=new URLSearchParams(); f.set('grant_type','refresh_token'); f.set('refresh_token',state.session.refreshToken);
- var rr=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:f.toString()});
- if(!rr.ok){ logout(); return r; }
- var tj=await rr.json(); state.session.accessToken=tj.access_token; state.session.refreshToken=tj.refresh_token||state.session.refreshToken; saveSession();
- h.Authorization='Bearer '+state.session.accessToken; return fetch(path,Object.assign({},opts,{headers:h}));
- }
-
- async function loadProfile(){ var r=await authFetch('/api/accounts/profile',{method:'GET'}); if(!r.ok) throw new Error('profile'); state.profile=await r.json(); }
- async function loadVault(){ var cr=await authFetch('/api/ciphers',{method:'GET'}); var fr=await authFetch('/api/folders',{method:'GET'}); if(!cr.ok||!fr.ok) throw new Error('vault'); var cj=await cr.json(); var fj=await fr.json(); state.ciphers=cj.data||[]; state.folders=fj.data||[]; if(!state.selectedCipherId&&state.ciphers.length>0) state.selectedCipherId=state.ciphers[0].id; await decryptVault(); }
- async function loadAdminData(){ if(!state.profile||state.profile.role!=='admin') return; var u=await authFetch('/api/admin/users',{method:'GET'}); if(u.ok){ var uj=await u.json(); state.users=uj.data||[]; } var i=await authFetch('/api/admin/invites?includeInactive=true',{method:'GET'}); if(i.ok){ var ij=await i.json(); state.invites=ij.data||[]; } }
-
- function selectedCount(){ return selectedCountUtil(state.selectedMap); }
- function cipherTypeKey(c){ return cipherTypeKeyUtil(c); }
- function cipherTypeLabel(c){
- var k=cipherTypeKey(c);
- if(k==='login') return t('typeLogin');
- if(k==='card') return t('typeCard');
- if(k==='identity') return t('typeIdentity');
- if(k==='note') return t('typeNote');
- return t('typeOther');
- }
- function folderNameById(id){
- for(var i=0;i ';
- }catch(_){
- box.innerHTML='QR unavailable
Use secret key below
';
- }
- }
- function buildSymmetricKeyBytes(){
- if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return null;
- try{
- var enc=base64ToBytes(state.session.symEncKey);
- var mac=base64ToBytes(state.session.symMacKey);
- if(enc.length!==32||mac.length!==32) return null;
- var out=new Uint8Array(64);
- out.set(enc,0);
- out.set(mac,32);
- return out;
- }catch(e){
- return null;
- }
- }
- function getUserCryptoKeys(){
- var sym=buildSymmetricKeyBytes();
- if(!sym||sym.length<64) return null;
- return { enc: sym.slice(0,32), mac: sym.slice(32,64) };
- }
- async function getCipherCryptoKeys(cipher){
- var user=getUserCryptoKeys();
- if(!user) return null;
- if(cipher&&cipher.key){
- try{
- var raw=await decryptBw(cipher.key,user.enc,user.mac);
- if(raw&&raw.length>=64) return { enc: raw.slice(0,32), mac: raw.slice(32,64), key: cipher.key };
- }catch(e){}
- }
- return { enc: user.enc, mac: user.mac, key: null };
- }
- async function encryptTextValue(v, enc, mac){
- var s=String(v==null?'':v);
- if(!s) return null;
- return encryptBw(new TextEncoder().encode(s), enc, mac);
- }
- function openCreateDraft(){
- state.detailMode='create';
- state.showSelectedPassword=true;
- state.createMenuOpen=false;
- state.detailDraft={
- id: '',
- type: 1,
- name: '',
- folderId: state.folderFilterId&&state.folderFilterId!==NO_FOLDER_FILTER?state.folderFilterId:'',
- reprompt: false,
- loginUsername: '',
- loginPassword: '',
- loginTotp: '',
- websites: [''],
- cardholderName: '',
- cardNumber: '',
- cardBrand: '',
- cardExpMonth: '',
- cardExpYear: '',
- cardCode: '',
- identTitle: '',
- identFirstName: '',
- identMiddleName: '',
- identLastName: '',
- identUsername: '',
- identCompany: '',
- identSsn: '',
- identPassportNumber: '',
- identLicenseNumber: '',
- identEmail: '',
- identPhone: '',
- identAddress1: '',
- identAddress2: '',
- identAddress3: '',
- identCity: '',
- identState: '',
- identPostalCode: '',
- identCountry: '',
- sshPrivateKey: '',
- sshPublicKey: '',
- sshFingerprint: '',
- customFields: [],
- notes: ''
- };
- }
- function openEditDraft(cipher){
- if(!cipher) return;
- var login=cipher.login||{};
- var uris=Array.isArray(login.uris)?login.uris:[];
- var ws=[]; for(var i=0;i'+l+''; }
- return opt('text',t('fieldText'))+opt('hidden',t('fieldHidden'))+opt('boolean',t('fieldBoolean'))+opt('linked',t('fieldLinked'));
- }
- function renderCreateMenu(){
- if(!state.createMenuOpen) return '';
- return '';
- }
- function renderFieldModal(){
- if(!state.fieldModalOpen) return '';
- return ''
- + '
'+t('addField')+' ✕ '
- + '
'+t('fieldType')+' '+renderFieldTypeOptions(state.fieldModalType)+'
'
- + '
'+t('fieldLabel')+'
'
- + '
'+t('fieldValue')+'
'
- + '
'+t('add')+' '+t('cancel')+'
'
- + '
';
- }
- function fieldTypeTextByNum(n){
- var x=parseFieldType(n);
- if(x===1) return t('fieldHidden');
- if(x===2) return t('fieldBoolean');
- if(x===3) return t('fieldLinked');
- return t('fieldText');
- }
- function renderCardBrandOptions(selected){
- var s=String(selected||'').toLowerCase();
- var brands=['','visa','mastercard','amex','discover','jcb','unionpay','dinersclub','maestro'];
- var labels={ '':'-- Select --', visa:'Visa', mastercard:'Mastercard', amex:'American Express', discover:'Discover', jcb:'JCB', unionpay:'UnionPay', dinersclub:'Diners Club', maestro:'Maestro' };
- var out='';
- for(var i=0;i'+labels[b]+'';
- }
- return out;
- }
- function renderMonthOptions(selected){
- var s=String(selected||'');
- var out='-- Select -- ';
- for(var m=1;m<=12;m++){
- var mm=m<10?('0'+m):String(m);
- out += ''+mm+' ';
- }
- return out;
- }
- function renderDraftTypeCards(d){
- var typeNum=Number(d&&d.type||1);
- if(typeNum===3){
- return ''
- + 'Card details
'
- + '
'
- + '
'
- + '
Brand
'+renderCardBrandOptions(d.cardBrand||'')+' '
- + '
Exp month
'+renderMonthOptions(d.cardExpMonth||'')+' '
- + '
'
- + '
';
- }
- if(typeNum===4){
- return ''
- + 'Personal details
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + 'Identity
'
- + '
'
- + '
'
- + '
'
- + '
'
- + 'Contact information
'
- + '
'
- + '
'
- + '
'
- + 'Address
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
'
- + '
';
- }
- if(typeNum===5){
- return ''
- + 'SSH key
'
- + '
'
- + '
'
- + '
'
- + '
';
- }
- if(typeNum===2){
- return '';
- }
- return ''
- + ''+t('credentials')+'
'
- + '
'
- + '
'
- + '
'
- + '
';
- }
- function renderReadOnlyCustomFields(cipher){
- var fs=Array.isArray(cipher&&cipher.fields)?cipher.fields:[];
- if(!fs.length) return '';
- var rows='';
- for(var i=0;i'+esc(name)+' ('+esc(fieldTypeTextByNum(typeNum))+')
'+esc(value||'')+'
';
- }
- return '';
- }
- function renderReadOnlyTypeDetails(c0, folderLabel, created, updated){
- var typeNum=Number(c0&&c0.type||1);
- var notes=c0&&((c0.decNotes||c0.notes)||'');
- var baseHead=''
- + ''
- + '
'+esc(c0.decName||c0.name||'')+'
'
- + '
'+t('folder')+': '+esc(folderLabel||t('noFolder'))+'
'
- + '
';
- var history=''
- + ''+t('itemHistory')+'
'
- + '
'+t('updatedAt')+': '+esc(updated)+'
'
- + '
'+t('createdAt')+': '+esc(created)+'
'
- + '
';
- if(typeNum===3){
- var c=c0.card||{};
- return baseHead
- + 'Card details
'
- + '
Cardholder name
'+esc(c.decCardholderName||c.cardholderName||'')+'
'
- + '
Number
'+esc(c.decNumber||c.number||'')+'
'
- + '
Brand
'+esc(c.decBrand||c.brand||'')+'
'
- + '
Exp month/year
'+esc((c.decExpMonth||c.expMonth||'')+' / '+(c.decExpYear||c.expYear||''))+'
'
- + '
Security code (CVV)
'+esc(c.decCode||c.code||'')+'
'
- + '
'
- + ''
- + renderReadOnlyCustomFields(c0)
- + history;
- }
- if(typeNum===4){
- var id=c0.identity||{};
- return baseHead
- + 'Personal details
'
- + '
Title
'+esc(id.decTitle||id.title||'')+'
'
- + '
First name
'+esc(id.decFirstName||id.firstName||'')+'
'
- + '
Middle name
'+esc(id.decMiddleName||id.middleName||'')+'
'
- + '
Last name
'+esc(id.decLastName||id.lastName||'')+'
'
- + '
Username
'+esc(id.decUsername||id.username||'')+'
'
- + '
Company
'+esc(id.decCompany||id.company||'')+'
'
- + '
'
- + 'Identity
'
- + '
SSN
'+esc(id.decSsn||id.ssn||'')+'
'
- + '
Passport number
'+esc(id.decPassportNumber||id.passportNumber||'')+'
'
- + '
License number
'+esc(id.decLicenseNumber||id.licenseNumber||'')+'
'
- + '
'
- + 'Contact information
'
- + '
Email
'+esc(id.decEmail||id.email||'')+'
'
- + '
Phone
'+esc(id.decPhone||id.phone||'')+'
'
- + '
'
- + 'Address
'
- + '
Address 1
'+esc(id.decAddress1||id.address1||'')+'
'
- + '
Address 2
'+esc(id.decAddress2||id.address2||'')+'
'
- + '
Address 3
'+esc(id.decAddress3||id.address3||'')+'
'
- + '
City / Town
'+esc(id.decCity||id.city||'')+'
'
- + '
State / Province
'+esc(id.decState||id.state||'')+'
'
- + '
ZIP / Postal code
'+esc(id.decPostalCode||id.postalCode||'')+'
'
- + '
Country
'+esc(id.decCountry||id.country||'')+'
'
- + '
'
- + ''
- + renderReadOnlyCustomFields(c0)
- + history;
- }
- if(typeNum===5){
- var ssh=c0.sshKey||{};
- var privateKey=ssh.decPrivateKey||ssh.privateKey||'';
- return baseHead
- + 'SSH key
'
- + '
Private key
'+esc(privateKey?new Array(Math.max(String(privateKey).length,12)+1).join('•'):'')+'
'
- + '
Public key
'+esc(ssh.decPublicKey||ssh.publicKey||'')+'
'
- + '
Fingerprint
'+esc(ssh.decFingerprint||ssh.fingerprint||'')+'
'
- + '
'
- + ''
- + renderReadOnlyCustomFields(c0)
- + history;
- }
- if(typeNum===2){
- return baseHead
- + ''
- + renderReadOnlyCustomFields(c0)
- + history;
- }
- var login=c0.login||{};
- var username=login.decUsername||login.username||'';
- var rawPwd=login.decPassword||login.password||'';
- var masked=rawPwd?new Array(Math.max(rawPwd.length,12)+1).join('•'):'';
- var pwdText=state.showSelectedPassword?rawPwd:masked;
- var totp=login.decTotp||login.totp||'';
- var uri0=firstCipherUri(c0);
- return baseHead
- + ''+t('credentials')+'
'
- + '
Username
'+esc(username)+'
'+t('copy')+' '
- + '
'+(state.showSelectedPassword?t('hide'):t('reveal'))+' '+t('copy')+'
'
- + (totp?('
'):'')
- + '
'
- + ''+t('autofillOptions')+'
'
- + '
'+t('website')+'
'+esc(uri0||'')+'
'+(uri0?(''+t('open')+' '):'')+(uri0?(''+t('copy')+' '):'')+'
'
- + '
'
- + renderReadOnlyCustomFields(c0)
- + history;
- }
- function renderLoginScreen(){
- return ''
- + ''
- + '
'+t('langSwitch')+'
'
- + '
'
- + ' '
- + renderMsg()
- + '
'
- + '
or
'
- + '
'
- + ' '+t('registerBtn')+' '
- + '
'
- + (state.pendingLogin ? ''
- + '
'+t('totpVerify')+' '+t('totpVerifySub')+'
'
- + (state.loginTotpError?'
'+esc(state.loginTotpError)+'
':'')
- + '
'
- + '
'
- : '')
- + '
'
- + '
';
- }
-
- function renderRegisterScreen(){
- return ''
- + ''
- + '
'+t('langSwitch')+'
'
- + '
'
- + ' '
- + renderMsg()
- + '
'
- + '
or
'
- + '
'
- + ' '+t('backToLogin')+' '
- + '
'
- + '
'
- + '
';
- }
-
- function renderLockedScreen(){
- var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : '');
- return ''
- + ''
- + '
'+t('langSwitch')+'
'
- + '
'
- + ' '
- + renderMsg()
- + (state.unlockError?('
'+esc(state.unlockError)+'
'):'')
- + '
'
- + '
or
'
- + '
'
- + ' Log Out '
- + '
'
- + '
'
- + '
';
- }
-
- function renderVaultTab(){
- var list=filteredCiphers();
- function renderFolderOptions(selectedId){
- var html=''+t('noFolder')+' ';
- for(var fi=0;fi'+esc(ff.decName||ff.name||ff.id)+'';
- }
- return html;
- }
- var rows='';
- for(var i=0;i🌐 ')
- : '🌐 ';
- rows += ''
- + ''
- + '
'
- + '
'+icon+'
'+esc(nameText)+'
'+esc(subtitle||'')+'
'
- + '
';
- }
- if(!rows) rows=''+t('noItems')+'
';
-
- var c0=selectedCipher();
- var detail=''+t('selectItem')+'
';
- if(state.detailMode==='create'){
- var dc=state.detailDraft||{};
- var wsHtml=''; var cws=Array.isArray(dc.websites)?dc.websites:[''];
- for(var wci=0;wci'+(cws.length>1?'✕ ':'')+'';
- }
- var cfHtml=''; var cfs=Array.isArray(dc.customFields)?dc.customFields:[];
- for(var cfi=0;cfi'+esc(cf.label||'')+' ('+esc(fieldTypeTextByNum(cf.type))+')
'+esc(cf.value||'')+'
✕ ';
- }
- detail=''
- + ''
- + renderDraftTypeCards(dc)
- + (Number(dc.type||1)===1?(''+t('autofillOptions')+'
'+wsHtml+'
'+t('addWebsite')+' '
- + '
'):'')
- + 'Additional options
'
- + '
'
- + '
Master password reprompt
'
- + '
'
- + 'Fields
'+cfHtml+'
'+t('addField')+' '
- + '';
- } else if(c0){
- var folderLabel=c0.folderId?folderNameById(c0.folderId):t('noFolder');
- var updated=c0.revisionDate||c0.updatedAt||'';
- var created=c0.creationDate||c0.createdAt||'';
- if(state.detailMode==='edit'){
- var de=state.detailDraft||{};
- var ewsHtml=''; var ews=Array.isArray(de.websites)?de.websites:[''];
- for(var wei=0;wei'+(ews.length>1?'✕ ':'')+'';
- }
- var efsHtml=''; var efs=Array.isArray(de.customFields)?de.customFields:[];
- for(var efi=0;efi'+esc(ef.label||'')+' ('+esc(fieldTypeTextByNum(ef.type))+')
'+esc(ef.value||'')+'
✕ ';
- }
- detail=''
- + ''
- + renderDraftTypeCards(de)
- + (Number(de.type||1)===1?(''+t('autofillOptions')+'
'+ewsHtml+'
'+t('addWebsite')+' '
- + '
'):'')
- + 'Additional options
'
- + '
'
- + '
Master password reprompt
'
- + '
'
- + 'Fields
'+efsHtml+'
'+t('addField')+' '
- + '';
- } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated)
- + '';
- }
-
- return ''
- + renderMsg()
- + ''+t('refresh')+' '+t('move')+' '+t('delete')+' ('+selectedCount()+') '+t('selectAll')+' '+t('clear')+'
'+rows+'
'+detail+renderFieldModal()+'
';
- }
-
- function renderSettingsTab(){
- var p=state.profile||{};
- var secret=currentTotpSecret();
- var lockMins = Number(state.lockTimeoutMinutes)||0;
- return ''
- + renderMsg()
- + ''+t('settings')+' '
- + ''
- + ''
- + ''
- + ''+t('totpSetup')+' QR loading...
Use secret key below
'+t('disableTotp')+' Disable action prompts for master password.
';
- }
- function renderTotpDisableModal(){
- if(!state.totpDisableOpen) return '';
- return ''
- + '';
- }
-
- function renderHelpTab(){
- return ''
- + ''+t('help')+' '
- + ''+t('helpSync')+' '+t('helpSync1')+' '+t('helpSync2')+' '+t('helpSync3')+' '
- + ''+t('helpErr')+' '+t('helpErr1')+' '+t('helpErr2')+' '+t('helpErr3')+' '+t('helpErr4')+' '
- + ''+t('helpTb')+' '+t('helpTb1')+' '+t('helpTb2')+' '+t('helpTb3')+' '+t('helpTb4')+' ';
- }
-
- function renderAdminTab(){
- var usersRows='';
- for(var i=0;i'+esc(u.email)+' '+esc(u.name||'')+' '+esc(u.role)+' '+esc(u.status)+' '
- + (canAct?''+(u.status==='active'?t('ban'):t('unban'))+' ':'')
- + (canAct?' '+t('delete')+' ':'')
- + ' ';
- }
- if(!usersRows) usersRows='No users found. ';
-
- var inviteRows='';
- for(var j=0;j'+esc(inv.code)+''+esc(inv.status)+' '+esc(inv.expiresAt)+' '
- + ''+t('copyLink')+' '
- + (inv.status==='active'?' '+t('revoke')+' ':'')
- + ' ';
- }
- if(!inviteRows) inviteRows='No invites found. ';
-
- return ''
- + renderMsg()
- + ''
- + '
'+t('admin')+' '
- + ''+t('refresh')+' '
- + ''
- + ''
- + ''+t('users')+' '+t('email')+' '+t('name')+' '+t('role')+' '+t('status')+' '+t('action')+' '+usersRows+'
'
- + ''+t('invites')+' Code '+t('status')+' Expires At '+t('action')+' '+inviteRows+'
';
- }
-
- function renderApp(){
- var isAdmin=state.profile&&state.profile.role==='admin';
- var showFolders=state.tab==='vault';
- var folders=''
- + '▾ '+t('allItems')+' '
- + '📁 '+t('noFolder')+' ';
- for(var i=0;i📁 '+esc(folderName)+' '; }
- var typeTree=''
- + '◉ '+t('typeAll')+' '
- + '⊕ '+t('typeLogin')+' '
- + '◧ '+t('typeCard')+' '
- + '◫ '+t('typeIdentity')+' '
- + '☰ '+t('typeNote')+' '
- + '• '+t('typeOther')+' ';
- var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab();
-
- return ''
- + ''
- + '
'
- + '
'
- + '
'
- + '
'+t('langSwitch')+'
'
- + '
'+esc(state.profile&&state.profile.email?state.profile.email:'')+' '
- + '
Lock '
- + '
'+t('logout')+' '
- + '
'
- + '
'
- + ''
- + (showFolders?(' '):'')
- + ' '+content+' '
- + '
'+renderTotpDisableModal();
- }
-
- function render(){
- var active = document.activeElement;
- var keepSearchFocus = false;
- var keepSearchSelStart = 0;
- var keepSearchSelEnd = 0;
- if (active instanceof HTMLInputElement && active.getAttribute('data-action') === 'vault-search') {
- keepSearchFocus = true;
- keepSearchSelStart = active.selectionStart == null ? 0 : active.selectionStart;
- keepSearchSelEnd = active.selectionEnd == null ? keepSearchSelStart : active.selectionEnd;
- }
- if(state.phase==='loading'){ app.innerHTML='' + renderToasts() + renderDialog(); return; }
- if(state.phase==='register'){ app.innerHTML=renderRegisterScreen() + renderToasts() + renderDialog(); return; }
- if(state.phase==='login'){ app.innerHTML=renderLoginScreen() + renderToasts() + renderDialog(); return; }
- if(state.phase==='locked'){ app.innerHTML=renderLockedScreen() + renderToasts() + renderDialog(); return; }
- var prevContent = app.querySelector('.content');
- var prevSidebar = app.querySelector('.sidebar');
- var prevVaultList = app.querySelector('.vault-list');
- var contentTop = prevContent ? prevContent.scrollTop : 0;
- var sidebarTop = prevSidebar ? prevSidebar.scrollTop : 0;
- var vaultListTop = prevVaultList ? prevVaultList.scrollTop : 0;
- app.innerHTML=renderApp() + renderToasts() + renderDialog();
- var nextContent = app.querySelector('.content');
- var nextSidebar = app.querySelector('.sidebar');
- var nextVaultList = app.querySelector('.vault-list');
- if(nextContent) nextContent.scrollTop = contentTop;
- if(nextSidebar) nextSidebar.scrollTop = sidebarTop;
- if(nextVaultList) nextVaultList.scrollTop = vaultListTop;
- if (keepSearchFocus) {
- var nextSearch = app.querySelector('input[data-action="vault-search"]');
- if (nextSearch instanceof HTMLInputElement) {
- nextSearch.focus();
- try { nextSearch.setSelectionRange(keepSearchSelStart, keepSearchSelEnd); } catch (_) {}
- }
- }
- updateLiveTotpDisplay();
- renderTotpSetupQr();
- }
-
- async function init(){
- var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession();
- loadLockSettings();
- ensureTotpTicker();
- ensureLockChannel();
- ensureAutoLockTicker();
- var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered);
- if(state.session){
- try{ await loadProfile(); state.phase='locked'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); }
- }
- state.phase=registered?'login':'register'; render();
- }
-
- async function onRegister(form){
- clearMsg();
- var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim();
- state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite;
- if(!email||!p) return setMsg('Please input email and password.', 'err');
- if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err');
- if(p!==p2) return setMsg('Passwords do not match.', 'err');
- try{
- var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em);
- var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']);
- var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64));
- var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})});
- var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err');
- state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode='';
- state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok');
- }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); }
- }
-
- async function onLoginPassword(form){
- clearMsg();
- var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||'');
- if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err');
- try{
- var d=await deriveLoginHash(state.loginEmail,state.loginPassword);
- var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access');
- var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()});
- var j=await jsonOrNull(resp);
- if(!resp.ok){
- if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; }
- return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err');
- }
- await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword);
- }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); }
- }
-
- async function onLoginTotp(form){
- if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err');
- var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; }
- var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken);
- var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()});
- var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; }
- state.loginTotpError='';
- await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword);
- }
-
- async function onLoginSuccess(tokenJson, masterKey, email, password){
- state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError='';
- await loadProfile();
- try{
- var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32);
- var symKeyBytes=await decryptBw(state.profile.key,ek,em);
- if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); }
- }catch(e){ console.warn('Key derivation failed:',e); }
- await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; state.lockLastActiveTs=Date.now();
- setMsg('Login success.', 'ok');
- }
- async function onSaveLockSettings(form){
- var fd=new FormData(form);
- var mins=Number(fd.get('lockTimeoutMinutes')||0);
- if(!Number.isFinite(mins)||mins<0) mins=15;
- state.lockTimeoutMinutes=mins;
- saveLockSettings();
- state.lockLastActiveTs=Date.now();
- setMsg('Lock settings saved.', 'ok');
- }
- async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); }
- async function onChangePassword(form){
- var fd=new FormData(form);
- var currentPassword=String(fd.get('currentPassword')||'');
- var newPassword=String(fd.get('newPassword')||'');
- var newPassword2=String(fd.get('newPassword2')||'');
- if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err');
- if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err');
- if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err');
- if(newPassword===currentPassword) return setMsg('New password must be different.', 'err');
- var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase();
- if(!email) return setMsg('Profile email missing.', 'err');
- try{
- var current=await deriveLoginHash(email,currentPassword);
- var userSym=buildSymmetricKeyBytes();
- if(!userSym){
- var oldEk=await hkdfExpand(current.masterKey,'enc',32);
- var oldEm=await hkdfExpand(current.masterKey,'mac',32);
- userSym=await decryptBw(state.profile.key,oldEk,oldEm);
- }
- if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err');
- var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32);
- var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32);
- var nextEk=await hkdfExpand(nextMasterKey,'enc',32);
- var nextEm=await hkdfExpand(nextMasterKey,'mac',32);
- var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm);
- var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})});
- var j=await jsonOrNull(r);
- if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err');
- logout();
- setMsg('Master password changed. Please log in again.', 'ok');
- }catch(e){
- setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err');
- }
- }
- async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); }
- function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); }
- async function onDisableTotpSubmit(form){
- var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||'');
- if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; }
- try{
- var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword);
- var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})});
- var j=await jsonOrNull(r);
- if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; }
- state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError='';
- render(); setMsg('TOTP disabled.', 'ok');
- }catch(e){
- state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e));
- render();
- }
- }
-
- async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var ok=await askConfirm({title:'Delete items',message:'Are you sure you want to delete '+ids.length+' selected items?',okText:'Yes',cancelText:'No',danger:true}); if(!ok) return; for(var i=0;i=0&&wi=0&&fi=0&&wi {
- const s = document.createElement('script');
- s.src = '/web/vendor/qrcode-generator.min.js';
- s.async = true;
- s.onload = () => resolve(null);
- s.onerror = () => resolve(null);
- document.head.appendChild(s);
- });
-}
-
-async function loadRuntimeConfig() {
- try {
- const resp = await fetch('/api/web/config', { method: 'GET' });
- if (!resp.ok) throw new Error('runtime config request failed');
- return await resp.json();
- } catch {
- return { defaultKdfIterations: 600000 };
- }
-}
-
-await ensureQrLibrary();
-const cfg = await loadRuntimeConfig();
-startNodewardenApp(cfg || { defaultKdfIterations: 600000 });
diff --git a/public/web/styles.css b/public/web/styles.css
deleted file mode 100644
index 324f78b..0000000
--- a/public/web/styles.css
+++ /dev/null
@@ -1,812 +0,0 @@
-
-:root {
- --bg: #F3F5F8;
- --panel: #FFFFFF;
- --line: #DEE2E6;
- --text-primary: #212529;
- --text-secondary: #6C757D;
- --primary: #175DDC;
- --primary-hover: #144eb8;
- --danger: #DC3545;
- --danger-hover: #C82333;
- --danger-bg: #F8D7DA;
- --success: #198754;
- --success-bg: #D1E7DD;
- --border-color: #DEE2E6;
- --radius: 6px;
- --radius-sm: 4px;
- --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
- --shadow: 0 4px 12px rgba(0,0,0,0.08);
- --shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
- --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- }
- * { box-sizing: border-box; }
- html, body { height: 100%; margin: 0; }
- body {
- color: var(--text-primary);
- font-family: var(--font-sans);
- background-color: var(--bg);
- -webkit-font-smoothing: antialiased;
- }
- #app { height: 100%; display: flex; flex-direction: column; }
-
- /* Auth Pages */
- .auth-page {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- padding: 20px;
- position: relative;
- }
- .lang-switch {
- position: absolute;
- top: 24px;
- right: 24px;
- cursor: pointer;
- color: var(--text-secondary);
- font-size: 14px;
- font-weight: 500;
- }
- .lang-switch:hover { color: var(--primary); }
- .auth-card {
- width: 100%;
- max-width: 420px;
- background: var(--panel);
- border: 1px solid var(--border-color);
- border-radius: var(--radius);
- padding: 40px;
- box-shadow: var(--shadow);
- }
- .auth-header {
- text-align: center;
- margin-bottom: 32px;
- }
- .auth-logo {
- width: 48px;
- height: 48px;
- background: var(--primary);
- border-radius: 12px;
- margin: 0 auto 16px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: white;
- font-weight: bold;
- font-size: 24px;
- }
- .auth-logo::after { content: "NW"; }
- .auth-title {
- font-size: 24px;
- font-weight: 700;
- color: var(--text-primary);
- margin-bottom: 8px;
- }
- .auth-subtitle {
- color: var(--text-secondary);
- font-size: 15px;
- }
- .auth-footer {
- margin-top: 24px;
- text-align: center;
- font-size: 14px;
- }
- .auth-footer a {
- color: var(--primary);
- text-decoration: none;
- font-weight: 500;
- }
- .auth-footer a:hover { text-decoration: underline; }
-
- /* Forms */
- .form-group { margin-bottom: 20px; }
- .form-label {
- display: block;
- margin-bottom: 8px;
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
- }
- .form-input {
- width: 100%;
- height: 42px;
- padding: 8px 12px;
- font-size: 15px;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-sm);
- background: #fff;
- color: var(--text-primary);
- transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
- }
- .form-input:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(23, 93, 220, 0.15);
- }
-
- /* Buttons */
- .btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- height: 42px;
- padding: 0 20px;
- font-size: 15px;
- font-weight: 600;
- border-radius: var(--radius-sm);
- border: 1px solid transparent;
- cursor: pointer;
- transition: all 0.15s ease-in-out;
- }
- .btn-primary {
- background: var(--primary);
- color: #fff;
- }
- .btn-primary:hover { background: var(--primary-hover); }
- .btn-secondary {
- background: #fff;
- border-color: var(--border-color);
- color: var(--text-primary);
- }
- .btn-secondary:hover { background: #F8F9FA; }
- .btn-danger {
- background: var(--danger);
- color: #fff;
- }
- .btn-danger:hover { background: var(--danger-hover); }
-
- /* Alerts */
- .alert {
- padding: 12px 16px;
- border-radius: var(--radius-sm);
- font-size: 14px;
- margin-bottom: 24px;
- border: 1px solid transparent;
- }
- .alert-success { background: var(--success-bg); color: var(--success); border-color: #BADBCC; }
- .alert-danger { background: var(--danger-bg); color: var(--danger); border-color: #F5C2C7; }
- .toast-stack {
- position: fixed;
- top: 16px;
- right: 16px;
- z-index: 1200;
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 10px;
- width: min(420px, calc(100vw - 24px));
- }
- .toast-item {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- border-radius: 10px;
- box-shadow: var(--shadow);
- border: 1px solid #c9e9d6;
- background: #dff4e5;
- color: #0f5132;
- padding: 14px 14px;
- overflow: hidden;
- }
- .toast-item.error {
- border-color: #f5c2c7;
- background: #f8d7da;
- color: #842029;
- }
- .toast-item.warning {
- border-color: #ffe69c;
- background: #fff3cd;
- color: #664d03;
- }
- .toast-text {
- font-size: 15px;
- font-weight: 600;
- padding-right: 10px;
- }
- .toast-close {
- border: none;
- background: transparent;
- color: inherit;
- font-size: 22px;
- cursor: pointer;
- line-height: 1;
- opacity: 0.8;
- }
- .toast-close:hover { opacity: 1; }
- .toast-bar {
- position: absolute;
- left: 0;
- bottom: 0;
- height: 3px;
- width: 100%;
- background: rgba(0,0,0,0.12);
- transform-origin: left center;
- animation: toastBar 4.5s linear forwards;
- }
- @keyframes toastBar { from { transform: scaleX(1); } to { transform: scaleX(0); } }
- .dialog-mask {
- position: fixed;
- inset: 0;
- background: rgba(17, 24, 39, 0.45);
- z-index: 1300;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 20px;
- }
- .dialog-card {
- width: min(540px, 100%);
- background: #fff;
- border: 1px solid var(--border-color);
- border-radius: 20px;
- box-shadow: var(--shadow-lg);
- padding: 24px 24px;
- text-align: center;
- }
- .dialog-icon {
- font-size: 34px;
- line-height: 1;
- color: #f4b400;
- margin-bottom: 12px;
- }
- .dialog-title {
- margin: 0 0 8px 0;
- font-size: 34px;
- line-height: 1.15;
- color: #0f172a;
- font-weight: 700;
- }
- .dialog-msg {
- margin: 0 auto 18px auto;
- color: #334155;
- font-size: 20px;
- max-width: 90%;
- }
- .dialog-btn {
- width: 100%;
- height: 56px;
- border-radius: 999px;
- font-size: 28px;
- margin-bottom: 10px;
- }
- .form-dialog {
- text-align: left;
- }
- .form-dialog .dialog-title {
- font-size: 30px;
- margin-bottom: 8px;
- text-align: center;
- }
- .form-dialog .dialog-msg {
- font-size: 16px;
- max-width: 100%;
- margin-bottom: 14px;
- text-align: center;
- }
- .form-dialog .dialog-btn {
- font-size: 22px;
- }
- .dialog-error {
- background: #f8d7da;
- border: 1px solid #f5c2c7;
- color: #842029;
- border-radius: 10px;
- padding: 10px 12px;
- font-size: 14px;
- margin: 0 0 12px 0;
- }
- .unlock-card {
- max-width: 620px;
- padding: 30px 34px;
- }
- .unlock-pwd-wrap {
- position: relative;
- margin-bottom: 14px;
- }
- .unlock-pwd-input {
- padding-right: 88px;
- height: 48px;
- border-radius: 10px;
- border-color: #3f5b9e;
- }
- .auth-page .form-input {
- height: 48px;
- border-radius: 10px;
- border-color: #3f5b9e;
- padding: 10px 12px;
- }
- .auth-page .form-input:focus {
- border-color: #3f5b9e;
- box-shadow: none;
- }
- .unlock-eye-btn {
- position: absolute;
- right: 42px;
- bottom: 8px;
- width: 30px;
- height: 30px;
- border: none;
- background: transparent;
- color: #233a72;
- font-size: 17px;
- cursor: pointer;
- }
- .unlock-main-btn {
- width: 100%;
- margin-top: 8px;
- height: 44px;
- border-radius: 999px;
- }
- .unlock-secondary-btn {
- width: 100%;
- height: 44px;
- border-radius: 999px;
- border-color: var(--primary);
- color: var(--primary);
- background: #fff;
- }
- .unlock-or {
- text-align: center;
- color: #1f2f4f;
- font-size: 16px;
- margin: 10px 0;
- line-height: 1;
- }
- .totp-qr-card {
- background:#fff;
- padding:16px;
- border:1px solid var(--border-color);
- border-radius:8px;
- width: 200px;
- min-height: 200px;
- display:flex;
- align-items:center;
- justify-content:center;
- }
- .totp-qr-fallback {
- width:100%;
- min-height:168px;
- border:1px dashed var(--border-color);
- border-radius:8px;
- display:flex;
- flex-direction:column;
- align-items:center;
- justify-content:center;
- color:var(--text-secondary);
- text-align:center;
- padding:8px;
- }
-
- /* App Layout */
- .navbar {
- height: 64px;
- background: var(--primary);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 24px;
- flex-shrink: 0;
- }
- .nav-brand {
- display: flex;
- align-items: center;
- gap: 12px;
- font-size: 20px;
- font-weight: 700;
- }
- .nav-logo {
- width: 32px;
- height: 32px;
- background: #fff;
- border-radius: 8px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--primary);
- font-weight: bold;
- font-size: 16px;
- }
- .nav-logo::after { content: "NW"; }
- .nav-links {
- display: flex;
- gap: 8px;
- }
- .nav-link {
- color: rgba(255,255,255,0.8);
- text-decoration: none;
- padding: 8px 16px;
- border-radius: var(--radius-sm);
- font-weight: 500;
- font-size: 15px;
- transition: all 0.15s;
- }
- .nav-link:hover { color: #fff; background: rgba(255,255,255,0.1); }
- .nav-link.active { color: #fff; background: rgba(255,255,255,0.2); }
- .nav-user {
- display: flex;
- align-items: center;
- }
- .nav-user .lang-switch {
- color: rgba(255,255,255,0.8);
- }
- .nav-user .lang-switch:hover { color: #fff; }
- .nav-user .btn-secondary {
- height: 32px;
- padding: 0 12px;
- font-size: 13px;
- background: rgba(255,255,255,0.1);
- border-color: transparent;
- color: #fff;
- }
- .nav-user .btn-secondary:hover { background: rgba(255,255,255,0.2); }
-
- .app-body {
- display: flex;
- flex: 1;
- overflow: hidden;
- width: min(1520px, calc(100vw - 40px));
- margin: 14px auto 16px;
- border: 1px solid var(--border-color);
- border-radius: 12px;
- background: #fff;
- box-shadow: var(--shadow);
- height: calc(100vh - 64px - 30px);
- }
- .sidebar {
- width: 300px;
- background: #fff;
- border-right: 1px solid var(--border-color);
- padding: 14px;
- overflow-y: auto;
- }
- .sidebar-block {
- border: 1px solid var(--border-color);
- border-radius: var(--radius);
- padding: 10px;
- background: #fff;
- margin-bottom: 12px;
- }
- .sidebar-title {
- font-size: 12px;
- font-weight: 700;
- color: var(--text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.05em;
- margin-bottom: 8px;
- }
- .search-input {
- width: 100%;
- height: 38px;
- border: 1px solid #2f6fec;
- border-radius: 9px;
- padding: 0 12px;
- font-size: 15px;
- outline: none;
- }
- .tree-btn {
- width: 100%;
- text-align: left;
- padding: 8px 10px;
- background: transparent;
- border: none;
- color: var(--text-primary);
- font-size: 14px;
- font-weight: 500;
- border-radius: var(--radius-sm);
- cursor: pointer;
- margin-bottom: 2px;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .tree-btn:hover { background: var(--bg); }
- .tree-btn.active { color: var(--primary); font-weight: 700; background: #eef4ff; }
-
- .content {
- flex: 1;
- padding: 16px 18px;
- overflow-y: auto;
- background: #F8FAFC;
- }
- .content .btn {
- height: 36px;
- padding: 0 16px;
- border-radius: 15px;
- }
- .content .btn-primary {
- background: var(--primary);
- border-color: var(--primary);
- color: #fff;
- }
- .content .btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
- .content .btn-secondary {
- background: #fff;
- border-color: var(--primary);
- color: var(--primary);
- }
- .content .btn-secondary:hover { background: #edf3ff; }
- .content .btn-danger {
- background: #fff;
- border-color: #e11d48;
- color: #e11d48;
- }
- .content .btn-danger:hover { background: #fff1f2; }
- .content .btn-danger-icon {
- width: 42px;
- padding: 0;
- border: none;
- background: transparent;
- color: #e11d48;
- font-size: 26px;
- line-height: 1;
- }
- .content .btn-danger-icon:hover {
- border: 1px solid #fecdd3;
- background: #fff1f2;
- }
-
- /* Vault Grid */
- .vault-grid {
- display: grid;
- grid-template-columns: minmax(380px, 44%) 1fr;
- gap: 16px;
- height: calc(100vh - 145px);
- }
- .vault-list-col { min-width: 0; display:flex; flex-direction:column; }
- .vault-list-head {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- margin-bottom: 8px;
- justify-content: flex-start;
- align-items: center;
- }
- .vault-list {
- background: #fff;
- border: 1px solid var(--border-color);
- border-radius: var(--radius);
- overflow-y: auto;
- flex: 1;
- }
- .vault-item {
- padding: 13px 14px;
- border-bottom: 1px solid var(--border-color);
- display: flex;
- align-items: center;
- gap: 10px;
- cursor: pointer;
- background: #fff;
- }
- .vault-item:hover { background: #f6f9ff; }
- .vault-item.active { background: #ecf3ff; }
- .vault-item:last-child { border-bottom: none; }
- .vault-item-check { width:18px; height:18px; }
- .vault-item-main { display:flex; align-items:center; gap:12px; min-width:0; }
- .vault-item-icon {
- width: 24px;
- height: 24px;
- border-radius: 5px;
- object-fit: contain;
- flex-shrink: 0;
- background: #fff;
- }
- .vault-item-icon-wrap { width:24px; height:24px; position:relative; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; }
- .vault-item-icon-fallback {
- display:inline-flex;
- align-items:center;
- justify-content:center;
- font-size: 24px;
- color: #6b7a90;
- border: none;
- background: transparent;
- }
- .vault-item-text { min-width:0; }
- .vault-item-title {
- color: #1457d6;
- font-size: 16px;
- font-weight: 700;
- line-height: 1.1;
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- }
- .vault-item-sub {
- color: #64748b;
- font-size: 13px;
- margin-top: 4px;
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- }
- .vault-detail {
- overflow-y: auto;
- padding-right: 2px;
- }
- .card {
- background: #fff;
- border: 1px solid var(--border-color);
- border-radius: 14px;
- padding: 16px 18px;
- margin-bottom: 14px;
- box-shadow: var(--shadow-sm);
- }
- .vault-detail-head .vault-detail-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
- .vault-detail-folder { color: #334155; font-size: 14px; }
- .card-title {
- font-size: 15px;
- font-weight: 700;
- color: #334155;
- margin-bottom: 10px;
- }
- .field-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 10px;
- padding: 10px 0;
- border-bottom: 1px solid #e8edf4;
- }
- .field-row:last-child { border-bottom: none; }
- .field-label { color: #64748b; font-size: 13px; margin-bottom: 3px; }
- .field-value { color: #0f172a; font-size: 17px; word-break: break-all; }
- .field-sub { color: #64748b; font-size: 12px; margin-top: 2px; }
- .icon-btn {
- border: 1px solid var(--border-color);
- background: #fff;
- border-radius: 8px;
- height: 30px;
- padding: 0 10px;
- font-size: 12px;
- font-weight: 600;
- cursor: pointer;
- margin-left: 6px;
- }
- .icon-btn:hover { background: #f8fafc; }
- .link-btn {
- border: none;
- background: transparent;
- color: #1457d6;
- font-weight: 700;
- font-size: 16px;
- cursor: pointer;
- padding: 2px 0;
- }
- .link-btn:hover { text-decoration: underline; }
- .create-menu-wrap { position: relative; }
- .create-menu {
- position: absolute;
- top: calc(100% + 4px);
- left: 0;
- min-width: 180px;
- background: #fff;
- border: 1px solid var(--border-color);
- border-radius: 10px;
- box-shadow: var(--shadow);
- z-index: 30;
- padding: 6px;
- }
- .create-menu-item {
- width: 100%;
- text-align: left;
- border: none;
- background: transparent;
- padding: 8px 10px;
- border-radius: 8px;
- font-size: 15px;
- cursor: pointer;
- }
- .create-menu-item:hover { background: #eff5ff; }
- .field-modal { max-width: 560px; }
- .field-modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px; }
- .field-modal-head h3 { margin: 0; }
- .history-line { color: #64748b; font-size: 13px; line-height: 1.8; }
- .detail-input {
- width: 100%;
- min-height: 36px;
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 7px 10px;
- font-size: 14px;
- background: #fff;
- color: #0f172a;
- }
- .detail-input:focus { outline: none; border-color: #2f6fec; box-shadow: 0 0 0 3px rgba(47,111,236,0.12); }
- .detail-textarea { min-height: 100px; resize: vertical; }
- .detail-actions {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-top: 8px;
- gap: 8px;
- }
- .detail-actions .btn { margin-right: 8px; }
- .detail-actions .btn:last-child { margin-right: 0; }
- .vault-empty {
- min-height: 120px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--text-secondary);
- padding: 20px;
- text-align: center;
- background: #fff;
- border: 1px solid var(--border-color);
- border-radius: var(--radius);
- }
-
- /* Common Components */
- .panel {
- background: #fff;
- border: 1px solid var(--border-color);
- border-radius: var(--radius);
- padding: 24px;
- margin-bottom: 24px;
- box-shadow: var(--shadow-sm);
- }
- .panel h3 { margin: 0 0 20px 0; font-size: 18px; font-weight: 600; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; }
-
- .table { width: 100%; border-collapse: collapse; font-size: 14px; }
- .table th, .table td { padding: 12px 16px; border-bottom: 1px solid var(--border-color); text-align: left; }
- .table th { font-weight: 600; color: var(--text-secondary); background: var(--bg); }
-
- .badge {
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 12px;
- font-weight: 600;
- background: var(--bg);
- color: var(--text-secondary);
- }
- .badge.success { background: var(--success-bg); color: var(--success); }
- .badge.danger { background: var(--danger-bg); color: var(--danger); }
-
- .kv { margin-bottom: 12px; font-size: 14px; line-height: 1.5; display: flex; }
- .kv b { color: var(--text-secondary); font-weight: 600; width: 120px; flex-shrink: 0; }
-
- .totp-mask {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
- }
- .totp-box {
- width: 100%;
- max-width: 400px;
- background: #fff;
- border-radius: var(--radius);
- padding: 32px;
- box-shadow: var(--shadow-lg);
- }
- @media (max-width: 980px) {
- .app-body {
- width: calc(100vw - 16px);
- margin: 8px auto;
- border-radius: 10px;
- }
- .sidebar { width: 280px; }
- }
- @media (max-width: 760px) {
- .app-body {
- width: 100%;
- margin: 0;
- border: none;
- border-radius: 0;
- box-shadow: none;
- height: calc(100vh - 64px);
- }
- .sidebar {
- width: 100%;
- border-right: none;
- border-bottom: 1px solid var(--border-color);
- }
- .vault-grid { grid-template-columns: 1fr; }
- }
diff --git a/public/web/vault-utils.js b/public/web/vault-utils.js
deleted file mode 100644
index 8aafd4d..0000000
--- a/public/web/vault-utils.js
+++ /dev/null
@@ -1,44 +0,0 @@
-export function parseFieldType(v) {
- if (v === null || v === undefined) return 0;
- if (typeof v === 'number' && isFinite(v)) return v === 1 || v === 2 || v === 3 ? v : 0;
- var s = String(v).trim().toLowerCase();
- if (s === '1' || s === 'hidden') return 1;
- if (s === '2' || s === 'boolean' || s === 'checkbox') return 2;
- if (s === '3' || s === 'linked' || s === 'link') return 3;
- return 0;
-}
-
-export function selectedCount(selectedMap) {
- var n = 0;
- for (var k in selectedMap) if (selectedMap[k]) n++;
- return n;
-}
-
-export function cipherTypeKey(c) {
- var tnum = Number(c && c.type || 1);
- if (tnum === 1) return 'login';
- if (tnum === 3) return 'card';
- if (tnum === 4) return 'identity';
- if (tnum === 2) return 'note';
- return 'other';
-}
-
-export function hostFromUri(uri) {
- if (!uri) return '';
- try {
- var normalized = /^https?:\/\//i.test(uri) ? uri : ('https://' + uri);
- return new URL(normalized).hostname || '';
- } catch (_) {
- return '';
- }
-}
-
-export function firstCipherUri(c) {
- var uris = c && c.login && Array.isArray(c.login.uris) ? c.login.uris : [];
- for (var i = 0; i < uris.length; i++) {
- var u = uris[i] && (uris[i].decUri || uris[i].uri);
- if (u) return u;
- }
- return '';
-}
-
diff --git a/public/web/vendor/qrcode-generator.min.js b/public/web/vendor/qrcode-generator.min.js
deleted file mode 100644
index 1434c6f..0000000
--- a/public/web/vendor/qrcode-generator.min.js
+++ /dev/null
@@ -1 +0,0 @@
-var qrcode=function(){function i(t,r){function a(t,r){g=function(t){for(var r=new Array(t),e=0;e>e&1);g[Math.floor(e/3)][e%3+l-8-3]=n}for(e=0;e<18;e+=1){n=!t&&1==(r>>e&1);g[e%3+l-8-3][Math.floor(e/3)]=n}},v=function(t,r){for(var e=f<<3|r,n=B.getBCHTypeInfo(e),o=0;o<15;o+=1){var i=!t&&1==(n>>o&1);o<6?g[o][8]=i:o<8?g[o+1][8]=i:g[l-15+o][8]=i}for(o=0;o<15;o+=1){i=!t&&1==(n>>o&1);o<8?g[8][l-o-1]=i:o<9?g[8][15-o-1+1]=i:g[8][15-o-1]=i}g[l-8][8]=!t},d=function(t,r){for(var e=-1,n=l-1,o=7,i=0,a=B.getMaskFunction(r),u=l-1;0>>o&1)),a(n,u-f)&&(c=!c),g[n][u-f]=c,-1==(o-=1)&&(i+=1,o=7)}if((n+=e)<0||l<=n){n-=e,e=-e;break}}},w=function(t,r,e){for(var n=b.getRSBlocks(t,r),o=M(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u',e+="";for(var n=0;n";for(var o=0;o ';e+=""}return e+=" ",e+=""},s.createSvgTag=function(t,r,e,n){var o={};"object"==typeof t&&(t=(o=t).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,f,c=s.getModuleCount()*t+2*r,g="";for(f="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",g+='",g+=n.text?''+p(n.text)+" ":"",g+=e.text?''+p(e.text)+" ":"",g+=' ',g+=' ',g+=" "},s.createDataURL=function(o,t){o=o||2,t=void 0===t?4*o:t;var r=s.getModuleCount()*o+2*t,i=t,a=r-t;return I(r,r,function(t,r){if(i<=t&&t "};var p=function(t){for(var r="",e=0;e":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return s.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*s.getModuleCount()+2*t,u=t,f=a-t,c={"██":"█","█ ":"▀"," █":"▄"," ":" "},g={"██":"▀","█ ":"▀"," █":" "," ":" "},l="";for(r=0;r>>8),r.push(255&o)):r.push(a)}}return r}};var r,t,a=1,u=2,o=4,f=8,y={L:1,M:0,Q:3,H:2},e=0,n=1,c=2,g=3,l=4,h=5,s=6,v=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],(t={}).getBCHTypeInfo=function(t){for(var r=t<<10;0<=d(r)-d(1335);)r^=1335<>>=1;return r}var w=function(){for(var r=new Array(256),e=new Array(256),t=0;t<8;t+=1)r[t]=1<>>8)},writeBytes:function(t,r,e){r=r||0,e=e||t.length;for(var n=0;n>>7-t%8&1)},put:function(t,r){for(var e=0;e>>r-e-1&1))},getLengthInBits:function(){return n},putBit:function(t){var r=Math.floor(n/8);e.length<=r&&e.push(0),t&&(e[r]|=128>>>n%8),n+=1}};return o},x=function(t){var r=a,n=t,e={getMode:function(){return r},getLength:function(t){return n.length},write:function(t){for(var r=n,e=0;e+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e=e.length){if(0==i)return-1;throw"unexpected end of file./"+i}var t=e.charAt(n);if(n+=1,"="==t)return i=0,-1;t.match(/^\s$/)||(o=o<<6|a(t.charCodeAt(0)),i+=6)}var r=o>>>i-8&255;return i-=8,r}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return r},I=function(t,r,e){for(var n=function(t,r){var n=t,o=r,l=new Array(t*r),e={setPixel:function(t,r,e){l[r*n+t]=e},write:function(t){t.writeString("GIF87a"),t.writeShort(n),t.writeShort(o),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(n),t.writeShort(o),t.writeByte(0);var r=i(2);t.writeByte(2);for(var e=0;255>>r!=0)throw"length over";for(;8<=n+r;)e.writeByte(255&(t<>>=8-n,n=o=0;o|=t<>>o-6),o-=6},t.flush=function(){if(0>6,128|63&n):n<55296||57344<=n?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}(function(){return qrcode});
\ No newline at end of file
diff --git a/webapp/index.html b/webapp/index.html
new file mode 100644
index 0000000..9945f47
--- /dev/null
+++ b/webapp/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ NodeWarden
+
+
+
+
+
+
diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx
new file mode 100644
index 0000000..502094e
--- /dev/null
+++ b/webapp/src/App.tsx
@@ -0,0 +1,756 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import { Link, Route, Switch, useLocation } from 'wouter';
+import { useQuery } from '@tanstack/react-query';
+import AuthViews from '@/components/AuthViews';
+import ConfirmDialog from '@/components/ConfirmDialog';
+import ToastHost from '@/components/ToastHost';
+import VaultPage from '@/components/VaultPage';
+import SettingsPage from '@/components/SettingsPage';
+import AdminPage from '@/components/AdminPage';
+import HelpPage from '@/components/HelpPage';
+import {
+ changeMasterPassword,
+ createCipher,
+ createAuthedFetch,
+ createInvite,
+ deleteCipher,
+ deleteUser,
+ deriveLoginHash,
+ bulkMoveCiphers,
+ getCiphers,
+ getFolders,
+ getProfile,
+ getSetupStatus,
+ getWebConfig,
+ listAdminInvites,
+ listAdminUsers,
+ loadSession,
+ loginWithPassword,
+ registerAccount,
+ revokeInvite,
+ saveSession,
+ setTotp,
+ setUserStatus,
+ updateCipher,
+ unlockVaultKey,
+ updateProfile,
+} from '@/lib/api';
+import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto';
+import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
+
+interface PendingTotp {
+ email: string;
+ passwordHash: string;
+ masterKey: Uint8Array;
+}
+
+export default function App() {
+ const [location, navigate] = useLocation();
+ const [phase, setPhase] = useState('loading');
+ const [session, setSessionState] = useState(null);
+ const [profile, setProfile] = useState(null);
+ const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000);
+ const [setupRegistered, setSetupRegistered] = useState(true);
+
+ const [loginValues, setLoginValues] = useState({ email: '', password: '' });
+ const [registerValues, setRegisterValues] = useState({
+ name: '',
+ email: '',
+ password: '',
+ password2: '',
+ inviteCode: '',
+ });
+ const [unlockPassword, setUnlockPassword] = useState('');
+ const [pendingTotp, setPendingTotp] = useState(null);
+ const [totpCode, setTotpCode] = useState('');
+
+ const [disableTotpOpen, setDisableTotpOpen] = useState(false);
+ const [disableTotpPassword, setDisableTotpPassword] = useState('');
+
+ const [confirm, setConfirm] = useState<{
+ title: string;
+ message: string;
+ danger?: boolean;
+ onConfirm: () => void;
+ } | null>(null);
+
+ const [toasts, setToasts] = useState([]);
+ const [decryptedFolders, setDecryptedFolders] = useState([]);
+ const [decryptedCiphers, setDecryptedCiphers] = useState([]);
+
+ function setSession(next: SessionState | null) {
+ setSessionState(next);
+ saveSession(next);
+ }
+
+ function pushToast(type: ToastMessage['type'], text: string) {
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
+ window.setTimeout(() => {
+ setToasts((prev) => prev.filter((x) => x.id !== id));
+ }, 4500);
+ }
+
+ const authedFetch = useMemo(
+ () =>
+ createAuthedFetch(
+ () => session,
+ (next) => {
+ setSession(next);
+ if (!next) {
+ setProfile(null);
+ setPhase(setupRegistered ? 'login' : 'register');
+ }
+ }
+ ),
+ [session, setupRegistered]
+ );
+
+ useEffect(() => {
+ let mounted = true;
+ (async () => {
+ const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]);
+ if (!mounted) return;
+ setSetupRegistered(setup.registered);
+ setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000));
+
+ const loaded = loadSession();
+ if (!loaded) {
+ setPhase(setup.registered ? 'login' : 'register');
+ return;
+ }
+ setSession(loaded);
+
+ try {
+ const profileResp = await getProfile(
+ createAuthedFetch(
+ () => loaded,
+ (next) => {
+ if (!next) return;
+ setSession(next);
+ }
+ )
+ );
+ if (!mounted) return;
+ setProfile(profileResp);
+ setPhase('locked');
+ } catch {
+ setSession(null);
+ setPhase(setup.registered ? 'login' : 'register');
+ }
+ })();
+
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) {
+ const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email };
+ const tempFetch = createAuthedFetch(
+ () => baseSession,
+ () => {}
+ );
+ const profileResp = await getProfile(tempFetch);
+ const keys = await unlockVaultKey(profileResp.key, masterKey);
+ const nextSession = { ...baseSession, ...keys };
+ setSession(nextSession);
+ setProfile(profileResp);
+ setPendingTotp(null);
+ setTotpCode('');
+ setPhase('app');
+ if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
+ navigate('/vault');
+ }
+ pushToast('success', 'Login success');
+ }
+
+ async function handleLogin() {
+ if (!loginValues.email || !loginValues.password) {
+ pushToast('error', 'Please input email and password');
+ return;
+ }
+ try {
+ const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations);
+ const token = await loginWithPassword(loginValues.email, derived.hash);
+ if ('access_token' in token && token.access_token) {
+ await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
+ return;
+ }
+ const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
+ if (tokenError.TwoFactorProviders) {
+ setPendingTotp({
+ email: loginValues.email.toLowerCase(),
+ passwordHash: derived.hash,
+ masterKey: derived.masterKey,
+ });
+ setTotpCode('');
+ return;
+ }
+ pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Login failed');
+ }
+ }
+
+ async function handleTotpVerify() {
+ if (!pendingTotp) return;
+ if (!totpCode.trim()) {
+ pushToast('error', 'Please input TOTP code');
+ return;
+ }
+ const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, totpCode.trim());
+ if ('access_token' in token && token.access_token) {
+ await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
+ return;
+ }
+ const tokenError = token as { error_description?: string; error?: string };
+ pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed');
+ }
+
+ async function handleRegister() {
+ if (!registerValues.email || !registerValues.password) {
+ pushToast('error', 'Please input email and password');
+ return;
+ }
+ if (registerValues.password.length < 12) {
+ pushToast('error', 'Master password must be at least 12 chars');
+ return;
+ }
+ if (registerValues.password !== registerValues.password2) {
+ pushToast('error', 'Passwords do not match');
+ return;
+ }
+ const resp = await registerAccount({
+ email: registerValues.email.toLowerCase(),
+ name: registerValues.name.trim(),
+ password: registerValues.password,
+ inviteCode: registerValues.inviteCode.trim(),
+ fallbackIterations: defaultKdfIterations,
+ });
+ if (!resp.ok) {
+ pushToast('error', resp.message);
+ return;
+ }
+ setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
+ setPhase('login');
+ pushToast('success', 'Registration succeeded. Please sign in.');
+ }
+
+ async function handleUnlock() {
+ if (!session || !profile) return;
+ if (!unlockPassword) {
+ pushToast('error', 'Please input master password');
+ return;
+ }
+ try {
+ const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
+ const keys = await unlockVaultKey(profile.key, derived.masterKey);
+ setSession({ ...session, ...keys });
+ setUnlockPassword('');
+ setPhase('app');
+ if (location === '/' || location === '/lock') navigate('/vault');
+ pushToast('success', 'Unlocked');
+ } catch {
+ pushToast('error', 'Unlock failed. Master password is incorrect.');
+ }
+ }
+
+ function handleLock() {
+ if (!session) return;
+ const nextSession = { ...session };
+ delete nextSession.symEncKey;
+ delete nextSession.symMacKey;
+ setSession(nextSession);
+ setPhase('locked');
+ navigate('/lock');
+ }
+
+ function handleLogout() {
+ setConfirm({
+ title: 'Log Out',
+ message: 'Are you sure you want to log out?',
+ onConfirm: () => {
+ setConfirm(null);
+ setSession(null);
+ setProfile(null);
+ setPendingTotp(null);
+ setPhase(setupRegistered ? 'login' : 'register');
+ navigate('/login');
+ },
+ });
+ }
+
+ const ciphersQuery = useQuery({
+ queryKey: ['ciphers', session?.accessToken],
+ queryFn: () => getCiphers(authedFetch),
+ enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
+ });
+ const foldersQuery = useQuery({
+ queryKey: ['folders', session?.accessToken],
+ queryFn: () => getFolders(authedFetch),
+ enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
+ });
+ const usersQuery = useQuery({
+ queryKey: ['admin-users', session?.accessToken],
+ queryFn: () => listAdminUsers(authedFetch),
+ enabled: phase === 'app' && profile?.role === 'admin',
+ });
+ const invitesQuery = useQuery({
+ queryKey: ['admin-invites', session?.accessToken],
+ queryFn: () => listAdminInvites(authedFetch),
+ enabled: phase === 'app' && profile?.role === 'admin',
+ });
+
+ useEffect(() => {
+ if (!session?.symEncKey || !session?.symMacKey) {
+ setDecryptedFolders([]);
+ setDecryptedCiphers([]);
+ return;
+ }
+ if (!foldersQuery.data || !ciphersQuery.data) return;
+
+ let active = true;
+ (async () => {
+ try {
+ const encKey = base64ToBytes(session.symEncKey!);
+ const macKey = base64ToBytes(session.symMacKey!);
+
+ const folders = await Promise.all(
+ foldersQuery.data.map(async (folder) => ({
+ ...folder,
+ decName: await decryptStr(folder.name, encKey, macKey),
+ }))
+ );
+
+ const ciphers = await Promise.all(
+ ciphersQuery.data.map(async (cipher) => {
+ let itemEnc = encKey;
+ let itemMac = macKey;
+ if (cipher.key) {
+ try {
+ const itemKey = await decryptBw(cipher.key, encKey, macKey);
+ itemEnc = itemKey.slice(0, 32);
+ itemMac = itemKey.slice(32, 64);
+ } catch {
+ // keep user key when item key decrypt fails
+ }
+ }
+
+ const nextCipher: Cipher = {
+ ...cipher,
+ decName: await decryptStr(cipher.name || '', itemEnc, itemMac),
+ decNotes: await decryptStr(cipher.notes || '', itemEnc, itemMac),
+ };
+ if (cipher.login) {
+ nextCipher.login = {
+ ...cipher.login,
+ decUsername: await decryptStr(cipher.login.username || '', itemEnc, itemMac),
+ decPassword: await decryptStr(cipher.login.password || '', itemEnc, itemMac),
+ decTotp: await decryptStr(cipher.login.totp || '', itemEnc, itemMac),
+ uris: await Promise.all(
+ (cipher.login.uris || []).map(async (u) => ({
+ ...u,
+ decUri: await decryptStr(u.uri || '', itemEnc, itemMac),
+ }))
+ ),
+ };
+ }
+ if (cipher.card) {
+ nextCipher.card = {
+ ...cipher.card,
+ decCardholderName: await decryptStr(cipher.card.cardholderName || '', itemEnc, itemMac),
+ decNumber: await decryptStr(cipher.card.number || '', itemEnc, itemMac),
+ decBrand: await decryptStr(cipher.card.brand || '', itemEnc, itemMac),
+ decExpMonth: await decryptStr(cipher.card.expMonth || '', itemEnc, itemMac),
+ decExpYear: await decryptStr(cipher.card.expYear || '', itemEnc, itemMac),
+ decCode: await decryptStr(cipher.card.code || '', itemEnc, itemMac),
+ };
+ }
+ if (cipher.identity) {
+ nextCipher.identity = {
+ ...cipher.identity,
+ decTitle: await decryptStr(cipher.identity.title || '', itemEnc, itemMac),
+ decFirstName: await decryptStr(cipher.identity.firstName || '', itemEnc, itemMac),
+ decMiddleName: await decryptStr(cipher.identity.middleName || '', itemEnc, itemMac),
+ decLastName: await decryptStr(cipher.identity.lastName || '', itemEnc, itemMac),
+ decUsername: await decryptStr(cipher.identity.username || '', itemEnc, itemMac),
+ decCompany: await decryptStr(cipher.identity.company || '', itemEnc, itemMac),
+ decSsn: await decryptStr(cipher.identity.ssn || '', itemEnc, itemMac),
+ decPassportNumber: await decryptStr(cipher.identity.passportNumber || '', itemEnc, itemMac),
+ decLicenseNumber: await decryptStr(cipher.identity.licenseNumber || '', itemEnc, itemMac),
+ decEmail: await decryptStr(cipher.identity.email || '', itemEnc, itemMac),
+ decPhone: await decryptStr(cipher.identity.phone || '', itemEnc, itemMac),
+ decAddress1: await decryptStr(cipher.identity.address1 || '', itemEnc, itemMac),
+ decAddress2: await decryptStr(cipher.identity.address2 || '', itemEnc, itemMac),
+ decAddress3: await decryptStr(cipher.identity.address3 || '', itemEnc, itemMac),
+ decCity: await decryptStr(cipher.identity.city || '', itemEnc, itemMac),
+ decState: await decryptStr(cipher.identity.state || '', itemEnc, itemMac),
+ decPostalCode: await decryptStr(cipher.identity.postalCode || '', itemEnc, itemMac),
+ decCountry: await decryptStr(cipher.identity.country || '', itemEnc, itemMac),
+ };
+ }
+ if (cipher.sshKey) {
+ nextCipher.sshKey = {
+ ...cipher.sshKey,
+ decPrivateKey: await decryptStr(cipher.sshKey.privateKey || '', itemEnc, itemMac),
+ decPublicKey: await decryptStr(cipher.sshKey.publicKey || '', itemEnc, itemMac),
+ decFingerprint: await decryptStr(cipher.sshKey.fingerprint || '', itemEnc, itemMac),
+ };
+ }
+ if (cipher.fields) {
+ nextCipher.fields = await Promise.all(
+ cipher.fields.map(async (field) => ({
+ ...field,
+ decName: await decryptStr(field.name || '', itemEnc, itemMac),
+ decValue: await decryptStr(field.value || '', itemEnc, itemMac),
+ }))
+ );
+ }
+ return nextCipher;
+ })
+ );
+
+ if (!active) return;
+ setDecryptedFolders(folders);
+ setDecryptedCiphers(ciphers);
+ } catch (error) {
+ if (!active) return;
+ pushToast('error', error instanceof Error ? error.message : 'Decrypt failed');
+ }
+ })();
+
+ return () => {
+ active = false;
+ };
+ }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]);
+
+ async function saveProfileAction(name: string, email: string) {
+ try {
+ const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() });
+ setProfile(updated);
+ pushToast('success', 'Profile updated');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Save profile failed');
+ }
+ }
+
+ async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) {
+ if (!profile) return;
+ if (!currentPassword || !nextPassword) {
+ pushToast('error', 'Current/new password is required');
+ return;
+ }
+ if (nextPassword.length < 12) {
+ pushToast('error', 'New password must be at least 12 chars');
+ return;
+ }
+ if (nextPassword !== nextPassword2) {
+ pushToast('error', 'New passwords do not match');
+ return;
+ }
+ try {
+ await changeMasterPassword(authedFetch, {
+ email: profile.email,
+ currentPassword,
+ newPassword: nextPassword,
+ currentIterations: defaultKdfIterations,
+ profileKey: profile.key,
+ });
+ handleLogout();
+ pushToast('success', 'Master password changed. Please login again.');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Change password failed');
+ }
+ }
+
+ async function enableTotpAction(secret: string, token: string) {
+ if (!secret.trim() || !token.trim()) {
+ pushToast('error', 'Secret and code are required');
+ return;
+ }
+ try {
+ await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
+ pushToast('success', 'TOTP enabled');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Enable TOTP failed');
+ }
+ }
+
+ async function disableTotpAction() {
+ if (!profile) return;
+ if (!disableTotpPassword) {
+ pushToast('error', 'Please input master password');
+ return;
+ }
+ try {
+ const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
+ await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
+ setDisableTotpOpen(false);
+ setDisableTotpPassword('');
+ pushToast('success', 'TOTP disabled');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed');
+ }
+ }
+
+ async function refreshVault() {
+ await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
+ pushToast('success', 'Vault synced');
+ }
+
+ async function createVaultItem(draft: VaultDraft) {
+ if (!session) return;
+ try {
+ await createCipher(authedFetch, session, draft);
+ await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
+ pushToast('success', 'Item created');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Create item failed');
+ throw error;
+ }
+ }
+
+ async function updateVaultItem(cipher: Cipher, draft: VaultDraft) {
+ if (!session) return;
+ try {
+ await updateCipher(authedFetch, session, cipher, draft);
+ await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
+ pushToast('success', 'Item updated');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Update item failed');
+ throw error;
+ }
+ }
+
+ async function deleteVaultItem(cipher: Cipher) {
+ try {
+ await deleteCipher(authedFetch, cipher.id);
+ await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
+ pushToast('success', 'Item deleted');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Delete item failed');
+ throw error;
+ }
+ }
+
+ async function bulkDeleteVaultItems(ids: string[]) {
+ try {
+ for (const id of ids) {
+ await deleteCipher(authedFetch, id);
+ }
+ await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
+ pushToast('success', 'Deleted selected items');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Bulk delete failed');
+ throw error;
+ }
+ }
+
+ async function bulkMoveVaultItems(ids: string[], folderId: string | null) {
+ try {
+ await bulkMoveCiphers(authedFetch, ids, folderId);
+ await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
+ pushToast('success', 'Moved selected items');
+ } catch (error) {
+ pushToast('error', error instanceof Error ? error.message : 'Bulk move failed');
+ throw error;
+ }
+ }
+
+ useEffect(() => {
+ if (phase === 'app' && location === '/') navigate('/vault');
+ }, [phase, location, navigate]);
+
+ if (phase === 'loading') {
+ return (
+ <>
+ Loading NodeWarden...
+ setToasts((prev) => prev.filter((x) => x.id !== id))} />
+ >
+ );
+ }
+
+ if (phase === 'register' || phase === 'login' || phase === 'locked') {
+ return (
+ <>
+ void handleLogin()}
+ onSubmitRegister={() => void handleRegister()}
+ onSubmitUnlock={() => void handleUnlock()}
+ onGotoLogin={() => setPhase('login')}
+ onGotoRegister={() => setPhase('register')}
+ onLogout={handleLogout}
+ />
+ setToasts((prev) => prev.filter((x) => x.id !== id))} />
+
+ void handleTotpVerify()}
+ onCancel={() => {
+ setPendingTotp(null);
+ setTotpCode('');
+ }}
+ >
+
+ TOTP Code
+ setTotpCode((e.currentTarget as HTMLInputElement).value)} />
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {profile && (
+ setDisableTotpOpen(true)}
+ />
+ )}
+
+
+ {
+ void usersQuery.refetch();
+ void invitesQuery.refetch();
+ }}
+ onCreateInvite={async (hours) => {
+ await createInvite(authedFetch, hours);
+ await invitesQuery.refetch();
+ pushToast('success', 'Invite created');
+ }}
+ onToggleUserStatus={async (userId, status) => {
+ await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
+ await usersQuery.refetch();
+ pushToast('success', 'User status updated');
+ }}
+ onDeleteUser={async (userId) => {
+ setConfirm({
+ title: 'Delete user',
+ message: 'Delete this user and all user data?',
+ danger: true,
+ onConfirm: () => {
+ setConfirm(null);
+ void (async () => {
+ await deleteUser(authedFetch, userId);
+ await usersQuery.refetch();
+ pushToast('success', 'User deleted');
+ })();
+ },
+ });
+ }}
+ onRevokeInvite={async (code) => {
+ await revokeInvite(authedFetch, code);
+ await invitesQuery.refetch();
+ pushToast('success', 'Invite revoked');
+ }}
+ />
+
+
+
+
+
+
+
+
+ confirm?.onConfirm()}
+ onCancel={() => setConfirm(null)}
+ />
+
+ void disableTotpAction()}
+ onCancel={() => {
+ setDisableTotpOpen(false);
+ setDisableTotpPassword('');
+ }}
+ >
+
+ Master Password
+ setDisableTotpPassword((e.currentTarget as HTMLInputElement).value)}
+ />
+
+
+
+ setToasts((prev) => prev.filter((x) => x.id !== id))} />
+ >
+ );
+}
diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx
new file mode 100644
index 0000000..3284e5a
--- /dev/null
+++ b/webapp/src/components/AdminPage.tsx
@@ -0,0 +1,116 @@
+import { useState } from 'preact/hooks';
+import type { AdminInvite, AdminUser } from '@/lib/types';
+
+interface AdminPageProps {
+ users: AdminUser[];
+ invites: AdminInvite[];
+ onRefresh: () => void;
+ onCreateInvite: (hours: number) => Promise;
+ onToggleUserStatus: (userId: string, currentStatus: string) => Promise;
+ onDeleteUser: (userId: string) => Promise;
+ onRevokeInvite: (code: string) => Promise;
+}
+
+export default function AdminPage(props: AdminPageProps) {
+ const [inviteHours, setInviteHours] = useState(168);
+
+ return (
+
+
+
+
Invites
+
+ Sync
+
+
+
+ setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
+ />
+ void props.onCreateInvite(inviteHours)}>
+ Create Invite
+
+
+
+
+
+ Code
+ Status
+ Actions
+
+
+
+ {props.invites.map((invite) => (
+
+ {invite.code}
+ {invite.status}
+
+
+ navigator.clipboard.writeText(invite.inviteLink || '')}
+ >
+ Copy Link
+
+ {invite.status === 'active' && (
+ void props.onRevokeInvite(invite.code)}>
+ Revoke
+
+ )}
+
+
+
+ ))}
+
+
+
+
+
+ Users
+
+
+
+ Email
+ Name
+ Role
+ Status
+ Actions
+
+
+
+ {props.users.map((user) => (
+
+ {user.email}
+ {user.name || '-'}
+ {user.role}
+ {user.status}
+
+
+ void props.onToggleUserStatus(user.id, user.status)}
+ >
+ {user.status === 'active' ? 'Ban' : 'Unban'}
+
+ {user.role !== 'admin' && (
+ void props.onDeleteUser(user.id)}>
+ Delete
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx
new file mode 100644
index 0000000..900dfbb
--- /dev/null
+++ b/webapp/src/components/AuthViews.tsx
@@ -0,0 +1,173 @@
+import { useState } from 'preact/hooks';
+
+interface LoginValues {
+ email: string;
+ password: string;
+}
+
+interface RegisterValues {
+ name: string;
+ email: string;
+ password: string;
+ password2: string;
+ inviteCode: string;
+}
+
+interface AuthViewsProps {
+ mode: 'login' | 'register' | 'locked';
+ loginValues: LoginValues;
+ registerValues: RegisterValues;
+ unlockPassword: string;
+ emailForLock: string;
+ onChangeLogin: (next: LoginValues) => void;
+ onChangeRegister: (next: RegisterValues) => void;
+ onChangeUnlock: (password: string) => void;
+ onSubmitLogin: () => void;
+ onSubmitRegister: () => void;
+ onSubmitUnlock: () => void;
+ onGotoLogin: () => void;
+ onGotoRegister: () => void;
+ onLogout: () => void;
+}
+
+function PasswordField(props: {
+ label: string;
+ value: string;
+ onInput: (v: string) => void;
+ autoFocus?: boolean;
+}) {
+ const [show, setShow] = useState(false);
+ return (
+
+ {props.label}
+
+ props.onInput((e.currentTarget as HTMLInputElement).value)}
+ autoFocus={props.autoFocus}
+ />
+ setShow((v) => !v)}>
+ {show ? 'Hide' : 'Show'}
+
+
+
+ );
+}
+
+export default function AuthViews(props: AuthViewsProps) {
+ if (props.mode === 'locked') {
+ return (
+
+
+
Unlock Vault
+
{props.emailForLock}
+
+
+ Unlock
+
+
or
+
+ Log Out
+
+
+
+ );
+ }
+
+ if (props.mode === 'register') {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
Log In
+
NodeWarden
+
+ Email
+ props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
+ />
+
+
props.onChangeLogin({ ...props.loginValues, password: v })}
+ autoFocus
+ />
+
+ Log In
+
+ or
+
+ Create Account
+
+
+
+ );
+}
diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..e179007
--- /dev/null
+++ b/webapp/src/components/ConfirmDialog.tsx
@@ -0,0 +1,37 @@
+import type { ComponentChildren } from 'preact';
+
+interface ConfirmDialogProps {
+ open: boolean;
+ title: string;
+ message: string;
+ confirmText?: string;
+ cancelText?: string;
+ danger?: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+ children?: ComponentChildren;
+}
+
+export default function ConfirmDialog(props: ConfirmDialogProps) {
+ if (!props.open) return null;
+ return (
+
+
+
!
+
{props.title}
+
{props.message}
+ {props.children}
+
+ {props.confirmText || 'Yes'}
+
+
+ {props.cancelText || 'No'}
+
+
+
+ );
+}
diff --git a/webapp/src/components/HelpPage.tsx b/webapp/src/components/HelpPage.tsx
new file mode 100644
index 0000000..45b2845
--- /dev/null
+++ b/webapp/src/components/HelpPage.tsx
@@ -0,0 +1,23 @@
+export default function HelpPage() {
+ return (
+
+
+ Upstream Sync
+
+ Use fork + scheduled sync workflow.
+ Before merging, compare API routes and auth flow changes.
+ After merging, run migration tests in local dev before deploy.
+
+
+
+ Common Errors
+
+ 401 Unauthorized: token expired, log in again.
+ 403 Account disabled: admin must unban your account.
+ 403 Invite invalid: invite expired or revoked.
+ 429 Too many requests: wait and retry.
+
+
+
+ );
+}
diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx
new file mode 100644
index 0000000..e2670df
--- /dev/null
+++ b/webapp/src/components/SettingsPage.tsx
@@ -0,0 +1,128 @@
+import { useMemo, useState } from 'preact/hooks';
+import qrcode from 'qrcode-generator';
+import type { Profile } from '@/lib/types';
+
+interface SettingsPageProps {
+ profile: Profile;
+ onSaveProfile: (name: string, email: string) => Promise;
+ onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise;
+ onEnableTotp: (secret: string, token: string) => Promise;
+ onOpenDisableTotp: () => void;
+}
+
+function randomBase32Secret(length: number): string {
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ const random = crypto.getRandomValues(new Uint8Array(length));
+ let out = '';
+ for (const x of random) out += alphabet[x % alphabet.length];
+ return out;
+}
+
+function buildOtpUri(email: string, secret: string): string {
+ const issuer = 'NodeWarden';
+ return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
+}
+
+export default function SettingsPage(props: SettingsPageProps) {
+ const [name, setName] = useState(props.profile.name || '');
+ const [email, setEmail] = useState(props.profile.email || '');
+ const [currentPassword, setCurrentPassword] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [newPassword2, setNewPassword2] = useState('');
+ const [secret, setSecret] = useState(randomBase32Secret(32));
+ const [token, setToken] = useState('');
+
+ const qrSvg = useMemo(() => {
+ const qr = qrcode(0, 'M');
+ qr.addData(buildOtpUri(email || props.profile.email, secret));
+ qr.make();
+ return qr.createSvgTag({ scalable: true, margin: 0 });
+ }, [email, props.profile.email, secret]);
+
+ return (
+
+
+
+
+
+
+ TOTP
+
+
+ Disable TOTP
+
+
+
+ );
+}
diff --git a/webapp/src/components/ToastHost.tsx b/webapp/src/components/ToastHost.tsx
new file mode 100644
index 0000000..3c5ef0b
--- /dev/null
+++ b/webapp/src/components/ToastHost.tsx
@@ -0,0 +1,23 @@
+import type { ToastMessage } from '@/lib/types';
+
+interface ToastHostProps {
+ toasts: ToastMessage[];
+ onClose: (id: string) => void;
+}
+
+export default function ToastHost({ toasts, onClose }: ToastHostProps) {
+ if (!toasts.length) return null;
+ return (
+
+ {toasts.map((toast) => (
+
+ {toast.text}
+ onClose(toast.id)}>
+ x
+
+
+
+ ))}
+
+ );
+}
diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx
new file mode 100644
index 0000000..426817a
--- /dev/null
+++ b/webapp/src/components/VaultPage.tsx
@@ -0,0 +1,1045 @@
+import { useEffect, useMemo, useState } from 'preact/hooks';
+import ConfirmDialog from '@/components/ConfirmDialog';
+import { calcTotpNow } from '@/lib/crypto';
+import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
+
+interface VaultPageProps {
+ ciphers: Cipher[];
+ folders: Folder[];
+ loading: boolean;
+ onRefresh: () => Promise;
+ onCreate: (draft: VaultDraft) => Promise;
+ onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise;
+ onDelete: (cipher: Cipher) => Promise;
+ onBulkDelete: (ids: string[]) => Promise;
+ onBulkMove: (ids: string[], folderId: string | null) => Promise;
+}
+
+type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh';
+
+interface TypeOption {
+ type: number;
+ label: string;
+}
+
+const CREATE_TYPE_OPTIONS: TypeOption[] = [
+ { type: 1, label: 'Login' },
+ { type: 3, label: 'Card' },
+ { type: 4, label: 'Identity' },
+ { type: 2, label: 'Note' },
+ { type: 5, label: 'SSH Key' },
+];
+
+const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
+ { value: 0, label: 'Text' },
+ { value: 1, label: 'Hidden' },
+ { value: 2, label: 'Boolean' },
+ { value: 3, label: 'Linked' },
+];
+
+function cipherTypeKey(type: number): TypeFilter {
+ if (type === 1) return 'login';
+ if (type === 3) return 'card';
+ if (type === 4) return 'identity';
+ if (type === 2) return 'note';
+ return 'ssh';
+}
+
+function cipherTypeLabel(type: number): string {
+ if (type === 1) return 'Login';
+ if (type === 3) return 'Card';
+ if (type === 4) return 'Identity';
+ if (type === 2) return 'Secure Note';
+ if (type === 5) return 'SSH Key';
+ return 'Item';
+}
+
+function typeIconText(type: number): string {
+ if (type === 1) return 'L';
+ if (type === 3) return 'C';
+ if (type === 4) return 'I';
+ if (type === 2) return 'N';
+ if (type === 5) return 'S';
+ return 'V';
+}
+
+function parseFieldType(value: number | string | null | undefined): CustomFieldType {
+ if (value === 1 || value === 2 || value === 3) return value;
+ if (value === '1' || String(value).toLowerCase() === 'hidden') return 1;
+ if (value === '2' || String(value).toLowerCase() === 'boolean') return 2;
+ if (value === '3' || String(value).toLowerCase() === 'linked') return 3;
+ return 0;
+}
+
+function fieldTypeLabel(type: CustomFieldType): string {
+ const found = FIELD_TYPE_OPTIONS.find((x) => x.value === type);
+ return found ? found.label : 'Text';
+}
+
+function firstCipherUri(cipher: Cipher): string {
+ const uris = cipher.login?.uris || [];
+ for (const uri of uris) {
+ const raw = uri.decUri || uri.uri || '';
+ if (raw.trim()) return raw.trim();
+ }
+ return '';
+}
+
+function hostFromUri(uri: string): string {
+ if (!uri.trim()) return '';
+ try {
+ const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
+ return new URL(normalized).hostname || '';
+ } catch {
+ return '';
+ }
+}
+
+function createEmptyDraft(type: number): VaultDraft {
+ return {
+ type,
+ name: '',
+ folderId: '',
+ notes: '',
+ reprompt: false,
+ loginUsername: '',
+ loginPassword: '',
+ loginTotp: '',
+ loginUris: [''],
+ cardholderName: '',
+ cardNumber: '',
+ cardBrand: '',
+ cardExpMonth: '',
+ cardExpYear: '',
+ cardCode: '',
+ identTitle: '',
+ identFirstName: '',
+ identMiddleName: '',
+ identLastName: '',
+ identUsername: '',
+ identCompany: '',
+ identSsn: '',
+ identPassportNumber: '',
+ identLicenseNumber: '',
+ identEmail: '',
+ identPhone: '',
+ identAddress1: '',
+ identAddress2: '',
+ identAddress3: '',
+ identCity: '',
+ identState: '',
+ identPostalCode: '',
+ identCountry: '',
+ sshPrivateKey: '',
+ sshPublicKey: '',
+ sshFingerprint: '',
+ customFields: [],
+ };
+}
+
+function draftFromCipher(cipher: Cipher): VaultDraft {
+ const draft = createEmptyDraft(Number(cipher.type || 1));
+ draft.id = cipher.id;
+ draft.name = cipher.decName || '';
+ draft.folderId = cipher.folderId || '';
+ draft.notes = cipher.decNotes || '';
+ draft.reprompt = Number(cipher.reprompt || 0) === 1;
+
+ if (cipher.login) {
+ draft.loginUsername = cipher.login.decUsername || '';
+ draft.loginPassword = cipher.login.decPassword || '';
+ draft.loginTotp = cipher.login.decTotp || '';
+ draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || '');
+ if (!draft.loginUris.length) draft.loginUris = [''];
+ }
+ if (cipher.card) {
+ draft.cardholderName = cipher.card.decCardholderName || '';
+ draft.cardNumber = cipher.card.decNumber || '';
+ draft.cardBrand = cipher.card.decBrand || '';
+ draft.cardExpMonth = cipher.card.decExpMonth || '';
+ draft.cardExpYear = cipher.card.decExpYear || '';
+ draft.cardCode = cipher.card.decCode || '';
+ }
+ if (cipher.identity) {
+ draft.identTitle = cipher.identity.decTitle || '';
+ draft.identFirstName = cipher.identity.decFirstName || '';
+ draft.identMiddleName = cipher.identity.decMiddleName || '';
+ draft.identLastName = cipher.identity.decLastName || '';
+ draft.identUsername = cipher.identity.decUsername || '';
+ draft.identCompany = cipher.identity.decCompany || '';
+ draft.identSsn = cipher.identity.decSsn || '';
+ draft.identPassportNumber = cipher.identity.decPassportNumber || '';
+ draft.identLicenseNumber = cipher.identity.decLicenseNumber || '';
+ draft.identEmail = cipher.identity.decEmail || '';
+ draft.identPhone = cipher.identity.decPhone || '';
+ draft.identAddress1 = cipher.identity.decAddress1 || '';
+ draft.identAddress2 = cipher.identity.decAddress2 || '';
+ draft.identAddress3 = cipher.identity.decAddress3 || '';
+ draft.identCity = cipher.identity.decCity || '';
+ draft.identState = cipher.identity.decState || '';
+ draft.identPostalCode = cipher.identity.decPostalCode || '';
+ draft.identCountry = cipher.identity.decCountry || '';
+ }
+ if (cipher.sshKey) {
+ draft.sshPrivateKey = cipher.sshKey.decPrivateKey || '';
+ draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
+ draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
+ }
+ draft.customFields = (cipher.fields || []).map((field) => ({
+ type: parseFieldType(field.type),
+ label: field.decName || '',
+ value: field.decValue || '',
+ }));
+
+ return draft;
+}
+
+function matchesTypeFilter(cipher: Cipher, typeFilter: TypeFilter): boolean {
+ if (typeFilter === 'all') return true;
+ if (typeFilter === 'favorite') return !!cipher.favorite;
+ return cipherTypeKey(Number(cipher.type || 1)) === typeFilter;
+}
+
+function maskSecret(value: string): string {
+ if (!value) return '';
+ return '*'.repeat(Math.max(8, Math.min(24, value.length)));
+}
+
+function formatTotp(code: string): string {
+ if (!code || code.length < 6) return code;
+ return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
+}
+
+function VaultListIcon({ cipher }: { cipher: Cipher }) {
+ const uri = firstCipherUri(cipher);
+ const host = hostFromUri(uri);
+ const [errored, setErrored] = useState(false);
+ if (host && !errored) {
+ return (
+ setErrored(true)}
+ />
+ );
+ }
+ return {typeIconText(Number(cipher.type || 1))} ;
+}
+
+function copyToClipboard(value: string): void {
+ if (!value.trim()) return;
+ void navigator.clipboard.writeText(value);
+}
+
+function openUri(raw: string): void {
+ const value = raw.trim();
+ if (!value) return;
+ const url = /^https?:\/\//i.test(value) ? value : `https://${value}`;
+ window.open(url, '_blank', 'noopener');
+}
+
+export default function VaultPage(props: VaultPageProps) {
+ const [searchInput, setSearchInput] = useState('');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchComposing, setSearchComposing] = useState(false);
+ const [typeFilter, setTypeFilter] = useState('all');
+ const [folderFilter, setFolderFilter] = useState('all');
+ const [selectedCipherId, setSelectedCipherId] = useState('');
+ const [selectedMap, setSelectedMap] = useState>({});
+ const [showPassword, setShowPassword] = useState(false);
+ const [createMenuOpen, setCreateMenuOpen] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [draft, setDraft] = useState(null);
+ const [fieldModalOpen, setFieldModalOpen] = useState(false);
+ const [fieldType, setFieldType] = useState(0);
+ const [fieldLabel, setFieldLabel] = useState('');
+ const [fieldValue, setFieldValue] = useState('');
+ const [localError, setLocalError] = useState('');
+ const [pendingDelete, setPendingDelete] = useState(null);
+ const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
+ const [moveOpen, setMoveOpen] = useState(false);
+ const [moveFolderId, setMoveFolderId] = useState('__none__');
+ const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ if (searchComposing) return;
+ const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90);
+ return () => window.clearTimeout(timer);
+ }, [searchInput, searchComposing]);
+
+ const filteredCiphers = useMemo(() => {
+ return props.ciphers.filter((cipher) => {
+ if (!matchesTypeFilter(cipher, typeFilter)) return false;
+ if (folderFilter === 'none' && cipher.folderId) return false;
+ if (folderFilter !== 'none' && folderFilter !== 'all' && cipher.folderId !== folderFilter) return false;
+ if (!searchQuery) return true;
+ const name = (cipher.decName || '').toLowerCase();
+ const username = (cipher.login?.decUsername || '').toLowerCase();
+ const uri = firstCipherUri(cipher).toLowerCase();
+ return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
+ });
+ }, [props.ciphers, folderFilter, typeFilter, searchQuery]);
+
+ useEffect(() => {
+ if (isCreating) return;
+ if (!filteredCiphers.length) {
+ if (selectedCipherId) setSelectedCipherId('');
+ return;
+ }
+ if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) {
+ setSelectedCipherId(filteredCiphers[0].id);
+ }
+ }, [filteredCiphers, selectedCipherId, isCreating]);
+
+ const selectedCipher = useMemo(
+ () => props.ciphers.find((x) => x.id === selectedCipherId) || null,
+ [props.ciphers, selectedCipherId]
+ );
+
+ useEffect(() => {
+ const raw = selectedCipher?.login?.decTotp || '';
+ if (!raw) {
+ setTotpLive(null);
+ return;
+ }
+ let stopped = false;
+ let timer = 0;
+ const tick = async () => {
+ try {
+ const now = await calcTotpNow(raw);
+ if (!stopped) setTotpLive(now);
+ } catch {
+ if (!stopped) setTotpLive(null);
+ }
+ };
+ void tick();
+ timer = window.setInterval(() => void tick(), 1000);
+ return () => {
+ stopped = true;
+ window.clearInterval(timer);
+ };
+ }, [selectedCipher?.id, selectedCipher?.login?.decTotp]);
+
+ const selectedCount = useMemo(
+ () => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0),
+ [selectedMap]
+ );
+
+ function folderName(id: string | null | undefined): string {
+ if (!id) return 'No Folder';
+ const folder = props.folders.find((x) => x.id === id);
+ return folder?.decName || folder?.name || id;
+ }
+
+ function listSubtitle(cipher: Cipher): string {
+ if (Number(cipher.type || 1) === 1) {
+ return cipher.login?.decUsername || firstCipherUri(cipher) || '';
+ }
+ return cipherTypeLabel(Number(cipher.type || 1));
+ }
+
+ function startCreate(type: number): void {
+ setDraft(createEmptyDraft(type));
+ setIsCreating(true);
+ setIsEditing(true);
+ setCreateMenuOpen(false);
+ setSelectedCipherId('');
+ setShowPassword(false);
+ setLocalError('');
+ }
+
+ function startEdit(): void {
+ if (!selectedCipher) return;
+ setDraft(draftFromCipher(selectedCipher));
+ setIsCreating(false);
+ setIsEditing(true);
+ setShowPassword(false);
+ setLocalError('');
+ }
+
+ function cancelEdit(): void {
+ setDraft(null);
+ setIsEditing(false);
+ setIsCreating(false);
+ setLocalError('');
+ }
+
+ function updateDraft(patch: Partial): void {
+ setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
+ }
+
+ function updateDraftCustomFields(nextFields: VaultDraftField[]): void {
+ setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev));
+ }
+
+ function updateDraftLoginUri(index: number, value: string): void {
+ setDraft((prev) => {
+ if (!prev) return prev;
+ const next = [...prev.loginUris];
+ next[index] = value;
+ return { ...prev, loginUris: next };
+ });
+ }
+
+ async function saveDraft(): Promise {
+ if (!draft) return;
+ if (!draft.name.trim()) {
+ setLocalError('Item name is required.');
+ return;
+ }
+ setBusy(true);
+ try {
+ if (isCreating) {
+ await props.onCreate(draft);
+ } else if (selectedCipher) {
+ await props.onUpdate(selectedCipher, draft);
+ }
+ setIsCreating(false);
+ setIsEditing(false);
+ setDraft(null);
+ setLocalError('');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function deleteSelected(): Promise {
+ if (!pendingDelete) return;
+ setBusy(true);
+ try {
+ await props.onDelete(pendingDelete);
+ setPendingDelete(null);
+ cancelEdit();
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function confirmBulkDelete(): Promise {
+ const ids = Object.entries(selectedMap)
+ .filter(([, selected]) => selected)
+ .map(([id]) => id);
+ if (!ids.length) return;
+ setBusy(true);
+ try {
+ await props.onBulkDelete(ids);
+ setSelectedMap({});
+ setBulkDeleteOpen(false);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function confirmBulkMove(): Promise {
+ const ids = Object.entries(selectedMap)
+ .filter(([, selected]) => selected)
+ .map(([id]) => id);
+ if (!ids.length) return;
+ const folderId = moveFolderId === '__none__' ? null : moveFolderId;
+ setBusy(true);
+ try {
+ await props.onBulkMove(ids, folderId);
+ setSelectedMap({});
+ setMoveOpen(false);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function syncVault(): Promise {
+ setBusy(true);
+ try {
+ await props.onRefresh();
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
Search
+
setSearchInput((e.currentTarget as HTMLInputElement).value)}
+ onCompositionStart={() => setSearchComposing(true)}
+ onCompositionEnd={(e) => {
+ setSearchComposing(false);
+ setSearchInput((e.currentTarget as HTMLInputElement).value);
+ }}
+ />
+
+
+
+
Types
+
setTypeFilter('all')}>
+ All Items
+
+
setTypeFilter('favorite')}>
+ Favorites
+
+
setTypeFilter('login')}>
+ Login
+
+
setTypeFilter('card')}>
+ Card
+
+
setTypeFilter('identity')}>
+ Identity
+
+
setTypeFilter('note')}>
+ Note
+
+
setTypeFilter('ssh')}>
+ SSH Key
+
+
+
+
+
Folders
+
setFolderFilter('all')}>
+ All
+
+
setFolderFilter('none')}>
+ No Folder
+
+ {props.folders.map((folder) => (
+
setFolderFilter(folder.id)}
+ >
+ {folder.decName || folder.name || folder.id}
+
+ ))}
+
+
+
+
+
+
void syncVault()}>
+ Sync
+
+
{
+ setMoveFolderId('__none__');
+ setMoveOpen(true);
+ }}
+ >
+ Move
+
+
setBulkDeleteOpen(true)}>
+ Delete ({selectedCount})
+
+
{
+ const map: Record = {};
+ for (const cipher of filteredCiphers) map[cipher.id] = true;
+ setSelectedMap(map);
+ }}
+ >
+ Select All
+
+
setSelectedMap({})}>
+ Cancel
+
+
+
setCreateMenuOpen((x) => !x)}>
+ + Add
+
+ {createMenuOpen && (
+
+ {CREATE_TYPE_OPTIONS.map((option) => (
+ startCreate(option.type)}>
+ {option.label}
+
+ ))}
+
+ )}
+
+
+
+
+ {filteredCiphers.map((cipher) => (
+
+
+ setSelectedMap((prev) => ({
+ ...prev,
+ [cipher.id]: (e.currentTarget as HTMLInputElement).checked,
+ }))
+ }
+ />
+
setSelectedCipherId(cipher.id)}>
+
+
+
+
+ {cipher.decName || '(No Name)'}
+ {listSubtitle(cipher)}
+
+
+
+ ))}
+ {!filteredCiphers.length &&
No items
}
+
+
+
+
+ {isEditing && draft && (
+ <>
+
+
{isCreating ? `New ${cipherTypeLabel(draft.type)}` : `Edit ${cipherTypeLabel(draft.type)}`}
+
+
+ Type
+ updateDraft({ type: Number((e.currentTarget as HTMLSelectElement).value) })}
+ >
+ {CREATE_TYPE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ Folder
+ updateDraft({ folderId: (e.currentTarget as HTMLSelectElement).value })}
+ >
+ No Folder
+ {props.folders.map((folder) => (
+
+ {folder.decName || folder.name || folder.id}
+
+ ))}
+
+
+
+
+ Name
+ updateDraft({ name: (e.currentTarget as HTMLInputElement).value })} />
+
+
+
+ {draft.type === 1 && (
+
+ )}
+
+ {draft.type === 3 && (
+
+ )}
+
+ {draft.type === 4 && (
+
+ )}
+ {draft.type === 5 && (
+
+
SSH Key
+
+ Private Key
+
+
+ Public Key
+
+
+ Fingerprint
+ updateDraft({ sshFingerprint: (e.currentTarget as HTMLInputElement).value })} />
+
+
+ )}
+
+
+
+
+
+ void saveDraft()}>
+ Confirm
+
+
+ Cancel
+
+
+ {!isCreating && selectedCipher && (
+
setPendingDelete(selectedCipher)}>
+ Delete
+
+ )}
+
+ {localError && {localError}
}
+ >
+ )}
+
+ {!isEditing && selectedCipher && (
+ <>
+
+
{selectedCipher.decName || '(No Name)'}
+
{folderName(selectedCipher.folderId)}
+
+
+ {selectedCipher.login && (
+
+
Login Credentials
+
+
Username
+
+ {selectedCipher.login.decUsername || ''}
+ copyToClipboard(selectedCipher.login?.decUsername || '')}>
+ Copy
+
+
+
+
+
Password
+
+ {showPassword ? selectedCipher.login.decPassword || '' : maskSecret(selectedCipher.login.decPassword || '')}
+ setShowPassword((v) => !v)}>
+ {showPassword ? 'Hide' : 'Reveal'}
+
+ copyToClipboard(selectedCipher.login?.decPassword || '')}>
+ Copy
+
+
+
+ {!!selectedCipher.login.decTotp && (
+
+
TOTP
+
+ {totpLive ? formatTotp(totpLive.code) : '------'}
+ Refresh in: {totpLive ? `${totpLive.remain}s` : '--'}
+ copyToClipboard(totpLive?.code || '')}>
+ Copy
+
+
+
+ )}
+
+ )}
+
+ {(selectedCipher.login?.uris || []).length > 0 && (
+
+
Autofill Options
+ {(selectedCipher.login?.uris || []).map((uri, index) => {
+ const value = uri.decUri || uri.uri || '';
+ if (!value.trim()) return null;
+ return (
+
+
Website
+
+ {value}
+ openUri(value)}>
+ Open
+
+ copyToClipboard(value)}>
+ Copy
+
+
+
+ );
+ })}
+
+ )}
+
+ {selectedCipher.card && (
+
+
Card Details
+
Cardholder Name {selectedCipher.card.decCardholderName || ''}
+
Number {selectedCipher.card.decNumber || ''}
+
Brand {selectedCipher.card.decBrand || ''}
+
Expiry {`${selectedCipher.card.decExpMonth || ''}/${selectedCipher.card.decExpYear || ''}`}
+
Security Code {selectedCipher.card.decCode || ''}
+
+ )}
+
+ {selectedCipher.identity && (
+
+
Identity Details
+
Name {`${selectedCipher.identity.decFirstName || ''} ${selectedCipher.identity.decLastName || ''}`.trim()}
+
Username {selectedCipher.identity.decUsername || ''}
+
Email {selectedCipher.identity.decEmail || ''}
+
Phone {selectedCipher.identity.decPhone || ''}
+
Company {selectedCipher.identity.decCompany || ''}
+
Address {[selectedCipher.identity.decAddress1, selectedCipher.identity.decAddress2, selectedCipher.identity.decAddress3, selectedCipher.identity.decCity, selectedCipher.identity.decState, selectedCipher.identity.decPostalCode, selectedCipher.identity.decCountry].filter(Boolean).join(', ')}
+
+ )}
+
+ {selectedCipher.sshKey && (
+
+
SSH Key
+
Private Key {maskSecret(selectedCipher.sshKey.decPrivateKey || '')}
+
Public Key {selectedCipher.sshKey.decPublicKey || ''}
+
Fingerprint {selectedCipher.sshKey.decFingerprint || ''}
+
+ )}
+
+
+
Notes
+
{selectedCipher.decNotes || ''}
+
+
+ {(selectedCipher.fields || []).length > 0 && (
+
+
Custom Fields
+ {(selectedCipher.fields || []).map((field, index) => (
+
+ {field.decName || 'Field'}
+ {field.decValue || ''}
+
+ ))}
+
+ )}
+
+
+
+
+ Edit
+
+
+
setPendingDelete(selectedCipher)}>
+ Delete
+
+
+ >
+ )}
+
+ {!isEditing && !selectedCipher && Select an item
}
+
+
+
+ {
+ if (!draft) return;
+ if (!fieldLabel.trim()) {
+ setLocalError('Field label is required.');
+ return;
+ }
+ updateDraftCustomFields([
+ ...draft.customFields,
+ {
+ type: fieldType,
+ label: fieldLabel.trim(),
+ value: fieldValue,
+ },
+ ]);
+ setFieldModalOpen(false);
+ setFieldType(0);
+ setFieldLabel('');
+ setFieldValue('');
+ setLocalError('');
+ }}
+ onCancel={() => {
+ setFieldModalOpen(false);
+ setFieldType(0);
+ setFieldLabel('');
+ setFieldValue('');
+ }}
+ >
+
+ Field Type
+ setFieldType(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
+ {FIELD_TYPE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ Field Label
+ setFieldLabel((e.currentTarget as HTMLInputElement).value)} />
+
+
+ Field Value
+ setFieldValue((e.currentTarget as HTMLInputElement).value)} />
+
+
+
+ void deleteSelected()}
+ onCancel={() => setPendingDelete(null)}
+ />
+
+ void confirmBulkDelete()}
+ onCancel={() => setBulkDeleteOpen(false)}
+ />
+
+ void confirmBulkMove()}
+ onCancel={() => setMoveOpen(false)}
+ >
+
+ Folder
+ setMoveFolderId((e.currentTarget as HTMLSelectElement).value)}>
+ No Folder
+ {props.folders.map((folder) => (
+
+ {folder.decName || folder.name || folder.id}
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts
new file mode 100644
index 0000000..29d7642
--- /dev/null
+++ b/webapp/src/lib/api.ts
@@ -0,0 +1,597 @@
+import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
+import type {
+ AdminInvite,
+ AdminUser,
+ Cipher,
+ Folder,
+ ListResponse,
+ Profile,
+ SessionState,
+ SetupStatusResponse,
+ TokenError,
+ TokenSuccess,
+ VaultDraft,
+ VaultDraftField,
+ WebConfigResponse,
+} from './types';
+
+const SESSION_KEY = 'nodewarden.web.session.v4';
+
+type SessionSetter = (next: SessionState | null) => void;
+
+export function loadSession(): SessionState | null {
+ try {
+ const raw = localStorage.getItem(SESSION_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as SessionState;
+ if (!parsed.accessToken || !parsed.refreshToken) return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export function saveSession(session: SessionState | null): void {
+ if (!session) {
+ localStorage.removeItem(SESSION_KEY);
+ return;
+ }
+ const persisted: SessionState = {
+ accessToken: session.accessToken,
+ refreshToken: session.refreshToken,
+ email: session.email,
+ symEncKey: session.symEncKey,
+ symMacKey: session.symMacKey,
+ };
+ localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
+}
+
+async function parseJson(response: Response): Promise {
+ const text = await response.text();
+ if (!text) return null;
+ try {
+ return JSON.parse(text) as T;
+ } catch {
+ return null;
+ }
+}
+
+export async function getSetupStatus(): Promise {
+ const resp = await fetch('/setup/status');
+ const body = await parseJson(resp);
+ return { registered: !!body?.registered };
+}
+
+export async function getWebConfig(): Promise {
+ const resp = await fetch('/api/web/config');
+ return (await parseJson(resp)) || {};
+}
+
+export interface PreloginResult {
+ hash: string;
+ masterKey: Uint8Array;
+ kdfIterations: number;
+}
+
+export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise {
+ const pre = await fetch('/identity/accounts/prelogin', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: email.toLowerCase() }),
+ });
+ if (!pre.ok) throw new Error('prelogin failed');
+ const data = (await parseJson<{ kdfIterations?: number }>(pre)) || {};
+ const iterations = Number(data.kdfIterations || fallbackIterations);
+ const masterKey = await pbkdf2(password, email.toLowerCase(), iterations, 32);
+ const hash = await pbkdf2(masterKey, password, 1, 32);
+ return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
+}
+
+export async function loginWithPassword(email: string, passwordHash: string, totpCode?: string): Promise {
+ const body = new URLSearchParams();
+ body.set('grant_type', 'password');
+ body.set('username', email.toLowerCase());
+ body.set('password', passwordHash);
+ body.set('scope', 'api offline_access');
+ if (totpCode) {
+ body.set('twoFactorProvider', '0');
+ body.set('twoFactorToken', totpCode);
+ }
+ const resp = await fetch('/identity/connect/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+ const json = (await parseJson(resp)) || {};
+ if (!resp.ok) return json;
+ return json;
+}
+
+export async function refreshAccessToken(refreshToken: string): Promise {
+ const body = new URLSearchParams();
+ body.set('grant_type', 'refresh_token');
+ body.set('refresh_token', refreshToken);
+ const resp = await fetch('/identity/connect/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ });
+ if (!resp.ok) return null;
+ const json = await parseJson(resp);
+ return json || null;
+}
+
+export async function registerAccount(args: {
+ email: string;
+ name: string;
+ password: string;
+ inviteCode?: string;
+ fallbackIterations: number;
+}): Promise<{ ok: true } | { ok: false; message: string }> {
+ try {
+ const { email, name, password, inviteCode, fallbackIterations } = args;
+ const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
+ const masterHash = await pbkdf2(masterKey, password, 1, 32);
+ const encKey = await hkdfExpand(masterKey, 'enc', 32);
+ const macKey = await hkdfExpand(masterKey, 'mac', 32);
+ const sym = crypto.getRandomValues(new Uint8Array(64));
+ const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
+
+ const keyPair = await crypto.subtle.generateKey(
+ {
+ name: 'RSA-OAEP',
+ modulusLength: 2048,
+ publicExponent: new Uint8Array([1, 0, 1]),
+ hash: 'SHA-1',
+ },
+ true,
+ ['encrypt', 'decrypt']
+ );
+ const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
+ const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
+ const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
+
+ const resp = await fetch('/api/accounts/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: email.toLowerCase(),
+ name,
+ masterPasswordHash: bytesToBase64(masterHash),
+ key: encryptedVaultKey,
+ kdf: 0,
+ kdfIterations: fallbackIterations,
+ inviteCode: inviteCode || undefined,
+ keys: {
+ publicKey: bytesToBase64(publicKey),
+ encryptedPrivateKey,
+ },
+ }),
+ });
+
+ if (!resp.ok) {
+ const json = await parseJson(resp);
+ return { ok: false, message: json?.error_description || json?.error || 'Register failed' };
+ }
+ return { ok: true };
+ } catch (error) {
+ return { ok: false, message: error instanceof Error ? error.message : 'Register failed' };
+ }
+}
+
+export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
+ return async function authedFetch(input: string, init: RequestInit = {}): Promise {
+ const session = getSession();
+ if (!session?.accessToken) throw new Error('Unauthorized');
+ const headers = new Headers(init.headers || {});
+ headers.set('Authorization', `Bearer ${session.accessToken}`);
+
+ let resp = await fetch(input, { ...init, headers });
+ if (resp.status !== 401 || !session.refreshToken) return resp;
+
+ const refreshed = await refreshAccessToken(session.refreshToken);
+ if (!refreshed?.access_token) {
+ setSession(null);
+ throw new Error('Session expired');
+ }
+
+ const nextSession: SessionState = {
+ ...session,
+ accessToken: refreshed.access_token,
+ refreshToken: refreshed.refresh_token || session.refreshToken,
+ };
+ setSession(nextSession);
+ saveSession(nextSession);
+
+ const retryHeaders = new Headers(init.headers || {});
+ retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
+ resp = await fetch(input, { ...init, headers: retryHeaders });
+ return resp;
+ };
+}
+
+export async function getProfile(authedFetch: (input: string, init?: RequestInit) => Promise): Promise {
+ const resp = await authedFetch('/api/accounts/profile');
+ if (!resp.ok) throw new Error('Failed to load profile');
+ const body = await parseJson(resp);
+ if (!body) throw new Error('Invalid profile');
+ return body;
+}
+
+export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> {
+ const encKey = await hkdfExpand(masterKey, 'enc', 32);
+ const macKey = await hkdfExpand(masterKey, 'mac', 32);
+ const keyBytes = await decryptBw(profileKey, encKey, macKey);
+ if (!keyBytes || keyBytes.length < 64) throw new Error('Invalid profile key');
+ return {
+ symEncKey: bytesToBase64(keyBytes.slice(0, 32)),
+ symMacKey: bytesToBase64(keyBytes.slice(32, 64)),
+ };
+}
+
+export async function getFolders(authedFetch: (input: string, init?: RequestInit) => Promise): Promise {
+ const resp = await authedFetch('/api/folders');
+ if (!resp.ok) throw new Error('Failed to load folders');
+ const body = await parseJson>(resp);
+ return body?.data || [];
+}
+
+export async function getCiphers(authedFetch: (input: string, init?: RequestInit) => Promise): Promise {
+ const resp = await authedFetch('/api/ciphers');
+ if (!resp.ok) throw new Error('Failed to load ciphers');
+ const body = await parseJson>(resp);
+ return body?.data || [];
+}
+
+export async function updateProfile(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ payload: { name: string; email: string }
+): Promise {
+ const resp = await authedFetch('/api/accounts/profile', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!resp.ok) throw new Error('Save profile failed');
+ const body = await parseJson(resp);
+ if (!body) throw new Error('Invalid profile');
+ return body;
+}
+
+export async function changeMasterPassword(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ args: {
+ email: string;
+ currentPassword: string;
+ newPassword: string;
+ currentIterations: number;
+ profileKey: string;
+ }
+): Promise {
+ const current = await deriveLoginHash(args.email, args.currentPassword, args.currentIterations);
+ const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
+ const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
+ const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
+ const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
+ const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
+ const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
+ const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
+ const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
+
+ const resp = await authedFetch('/api/accounts/password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ currentPasswordHash: current.hash,
+ newMasterPasswordHash: bytesToBase64(nextHash),
+ newKey,
+ kdf: 0,
+ kdfIterations: current.kdfIterations,
+ }),
+ });
+ if (!resp.ok) throw new Error('Change master password failed');
+}
+
+export async function setTotp(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ payload: { enabled: boolean; token?: string; secret?: string; masterPasswordHash?: string }
+): Promise {
+ const resp = await authedFetch('/api/accounts/totp', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!resp.ok) {
+ const body = await parseJson(resp);
+ throw new Error(body?.error_description || body?.error || 'TOTP update failed');
+ }
+}
+
+export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise): Promise {
+ const resp = await authedFetch('/api/admin/users');
+ if (!resp.ok) throw new Error('Failed to load users');
+ const body = await parseJson>(resp);
+ return body?.data || [];
+}
+
+export async function listAdminInvites(authedFetch: (input: string, init?: RequestInit) => Promise): Promise {
+ const resp = await authedFetch('/api/admin/invites?includeInactive=true');
+ if (!resp.ok) throw new Error('Failed to load invites');
+ const body = await parseJson>(resp);
+ return body?.data || [];
+}
+
+export async function createInvite(authedFetch: (input: string, init?: RequestInit) => Promise, hours: number): Promise {
+ const resp = await authedFetch('/api/admin/invites', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ expiresInHours: hours }),
+ });
+ if (!resp.ok) throw new Error('Create invite failed');
+}
+
+export async function revokeInvite(authedFetch: (input: string, init?: RequestInit) => Promise, code: string): Promise {
+ const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
+ if (!resp.ok) throw new Error('Revoke invite failed');
+}
+
+export async function setUserStatus(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ userId: string,
+ status: 'active' | 'banned'
+): Promise {
+ const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status }),
+ });
+ if (!resp.ok) throw new Error('Update user status failed');
+}
+
+export async function deleteUser(authedFetch: (input: string, init?: RequestInit) => Promise, userId: string): Promise {
+ const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
+ if (!resp.ok) throw new Error('Delete user failed');
+}
+
+function asNullable(v: string): string | null {
+ const s = String(v || '').trim();
+ return s ? s : null;
+}
+
+function parseFieldType(v: number | string): 0 | 1 | 2 | 3 {
+ if (typeof v === 'number') {
+ if (v === 1 || v === 2 || v === 3) return v;
+ return 0;
+ }
+ const s = String(v).trim().toLowerCase();
+ if (s === '1' || s === 'hidden') return 1;
+ if (s === '2' || s === 'boolean' || s === 'checkbox') return 2;
+ if (s === '3' || s === 'linked' || s === 'link') return 3;
+ return 0;
+}
+
+async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array): Promise {
+ const s = String(value || '');
+ if (!s.trim()) return null;
+ return encryptBw(new TextEncoder().encode(s), enc, mac);
+}
+
+async function encryptCustomFields(fields: VaultDraftField[], enc: Uint8Array, mac: Uint8Array): Promise> {
+ const out: Array<{ type: number; name: string | null; value: string | null }> = [];
+ for (const field of fields || []) {
+ const label = String(field.label || '').trim();
+ if (!label) continue;
+ out.push({
+ type: parseFieldType(field.type),
+ name: await encryptTextValue(label, enc, mac),
+ value: await encryptTextValue(String(field.value || ''), enc, mac),
+ });
+ }
+ return out;
+}
+
+async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise> {
+ const out: Array<{ uri: string | null; match: null }> = [];
+ for (const uri of uris || []) {
+ const trimmed = String(uri || '').trim();
+ if (!trimmed) continue;
+ out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null });
+ }
+ return out;
+}
+
+async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> {
+ if (cipher?.key) {
+ try {
+ const raw = await decryptBw(cipher.key, userEnc, userMac);
+ if (raw.length >= 64) return { enc: raw.slice(0, 32), mac: raw.slice(32, 64), key: cipher.key };
+ } catch {
+ // use user key
+ }
+ }
+ return { enc: userEnc, mac: userMac, key: null };
+}
+
+export async function createCipher(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ session: SessionState,
+ draft: VaultDraft
+): Promise {
+ if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
+ const enc = base64ToBytes(session.symEncKey);
+ const mac = base64ToBytes(session.symMacKey);
+ const type = Number(draft.type || 1);
+
+ const payload: Record = {
+ type,
+ folderId: asNullable(draft.folderId),
+ reprompt: draft.reprompt ? 1 : 0,
+ name: await encryptTextValue(draft.name, enc, mac),
+ notes: await encryptTextValue(draft.notes, enc, mac),
+ login: null,
+ card: null,
+ identity: null,
+ secureNote: null,
+ sshKey: null,
+ fields: await encryptCustomFields(draft.customFields || [], enc, mac),
+ };
+
+ if (type === 1) {
+ payload.login = {
+ username: await encryptTextValue(draft.loginUsername, enc, mac),
+ password: await encryptTextValue(draft.loginPassword, enc, mac),
+ totp: await encryptTextValue(draft.loginTotp, enc, mac),
+ uris: await encryptUris(draft.loginUris || [], enc, mac),
+ };
+ } else if (type === 3) {
+ payload.card = {
+ cardholderName: await encryptTextValue(draft.cardholderName, enc, mac),
+ number: await encryptTextValue(draft.cardNumber, enc, mac),
+ brand: await encryptTextValue(draft.cardBrand, enc, mac),
+ expMonth: await encryptTextValue(draft.cardExpMonth, enc, mac),
+ expYear: await encryptTextValue(draft.cardExpYear, enc, mac),
+ code: await encryptTextValue(draft.cardCode, enc, mac),
+ };
+ } else if (type === 4) {
+ payload.identity = {
+ title: await encryptTextValue(draft.identTitle, enc, mac),
+ firstName: await encryptTextValue(draft.identFirstName, enc, mac),
+ middleName: await encryptTextValue(draft.identMiddleName, enc, mac),
+ lastName: await encryptTextValue(draft.identLastName, enc, mac),
+ username: await encryptTextValue(draft.identUsername, enc, mac),
+ company: await encryptTextValue(draft.identCompany, enc, mac),
+ ssn: await encryptTextValue(draft.identSsn, enc, mac),
+ passportNumber: await encryptTextValue(draft.identPassportNumber, enc, mac),
+ licenseNumber: await encryptTextValue(draft.identLicenseNumber, enc, mac),
+ email: await encryptTextValue(draft.identEmail, enc, mac),
+ phone: await encryptTextValue(draft.identPhone, enc, mac),
+ address1: await encryptTextValue(draft.identAddress1, enc, mac),
+ address2: await encryptTextValue(draft.identAddress2, enc, mac),
+ address3: await encryptTextValue(draft.identAddress3, enc, mac),
+ city: await encryptTextValue(draft.identCity, enc, mac),
+ state: await encryptTextValue(draft.identState, enc, mac),
+ postalCode: await encryptTextValue(draft.identPostalCode, enc, mac),
+ country: await encryptTextValue(draft.identCountry, enc, mac),
+ };
+ } else if (type === 5) {
+ payload.sshKey = {
+ privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac),
+ publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac),
+ fingerprint: await encryptTextValue(draft.sshFingerprint, enc, mac),
+ };
+ } else if (type === 2) {
+ payload.secureNote = { type: 0 };
+ }
+
+ const resp = await authedFetch('/api/ciphers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!resp.ok) throw new Error('Create item failed');
+}
+
+export async function updateCipher(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ session: SessionState,
+ cipher: Cipher,
+ draft: VaultDraft
+): Promise {
+ if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
+ const userEnc = base64ToBytes(session.symEncKey);
+ const userMac = base64ToBytes(session.symMacKey);
+ const keys = await getCipherKeys(cipher, userEnc, userMac);
+ const type = Number(draft.type || cipher.type || 1);
+
+ const payload: Record = {
+ id: cipher.id,
+ type,
+ key: keys.key,
+ folderId: asNullable(draft.folderId),
+ favorite: !!cipher.favorite,
+ reprompt: draft.reprompt ? 1 : 0,
+ name: await encryptTextValue(draft.name, keys.enc, keys.mac),
+ notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
+ login: null,
+ card: null,
+ identity: null,
+ secureNote: null,
+ sshKey: null,
+ fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
+ };
+
+ if (type === 1) {
+ payload.login = {
+ username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
+ password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
+ totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
+ uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
+ };
+ } else if (type === 3) {
+ payload.card = {
+ cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
+ number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
+ brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
+ expMonth: await encryptTextValue(draft.cardExpMonth, keys.enc, keys.mac),
+ expYear: await encryptTextValue(draft.cardExpYear, keys.enc, keys.mac),
+ code: await encryptTextValue(draft.cardCode, keys.enc, keys.mac),
+ };
+ } else if (type === 4) {
+ payload.identity = {
+ title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
+ firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
+ middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
+ lastName: await encryptTextValue(draft.identLastName, keys.enc, keys.mac),
+ username: await encryptTextValue(draft.identUsername, keys.enc, keys.mac),
+ company: await encryptTextValue(draft.identCompany, keys.enc, keys.mac),
+ ssn: await encryptTextValue(draft.identSsn, keys.enc, keys.mac),
+ passportNumber: await encryptTextValue(draft.identPassportNumber, keys.enc, keys.mac),
+ licenseNumber: await encryptTextValue(draft.identLicenseNumber, keys.enc, keys.mac),
+ email: await encryptTextValue(draft.identEmail, keys.enc, keys.mac),
+ phone: await encryptTextValue(draft.identPhone, keys.enc, keys.mac),
+ address1: await encryptTextValue(draft.identAddress1, keys.enc, keys.mac),
+ address2: await encryptTextValue(draft.identAddress2, keys.enc, keys.mac),
+ address3: await encryptTextValue(draft.identAddress3, keys.enc, keys.mac),
+ city: await encryptTextValue(draft.identCity, keys.enc, keys.mac),
+ state: await encryptTextValue(draft.identState, keys.enc, keys.mac),
+ postalCode: await encryptTextValue(draft.identPostalCode, keys.enc, keys.mac),
+ country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac),
+ };
+ } else if (type === 5) {
+ payload.sshKey = {
+ privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
+ publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
+ fingerprint: await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac),
+ };
+ } else if (type === 2) {
+ payload.secureNote = { type: 0 };
+ }
+
+ const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!resp.ok) throw new Error('Update item failed');
+}
+
+export async function deleteCipher(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ cipherId: string
+): Promise {
+ const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
+ if (!resp.ok) throw new Error('Delete item failed');
+}
+
+export async function bulkMoveCiphers(
+ authedFetch: (input: string, init?: RequestInit) => Promise,
+ ids: string[],
+ folderId: string | null
+): Promise {
+ const resp = await authedFetch('/api/ciphers/move', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ids, folderId }),
+ });
+ if (!resp.ok) throw new Error('Bulk move failed');
+}
diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts
new file mode 100644
index 0000000..ee5eaa5
--- /dev/null
+++ b/webapp/src/lib/crypto.ts
@@ -0,0 +1,174 @@
+export function bytesToBase64(bytes: Uint8Array): string {
+ let s = '';
+ for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
+ return btoa(s);
+}
+
+export function base64ToBytes(b64: string): Uint8Array {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
+ const out = new Uint8Array(a.length + b.length);
+ out.set(a, 0);
+ out.set(b, a.length);
+ return out;
+}
+
+function toBufferSource(bytes: Uint8Array): ArrayBuffer {
+ return new Uint8Array(bytes).buffer;
+}
+
+export async function pbkdf2(
+ passwordOrBytes: string | Uint8Array,
+ saltOrBytes: string | Uint8Array,
+ iterations: number,
+ keyLen: number
+): Promise {
+ const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
+ const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
+ const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
+ const bits = await crypto.subtle.deriveBits(
+ { name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
+ key,
+ keyLen * 8
+ );
+ return new Uint8Array(bits);
+}
+
+export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise {
+ const infoBytes = new TextEncoder().encode(info || '');
+ const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
+ const result = new Uint8Array(length);
+ let previous = new Uint8Array(0);
+ let offset = 0;
+ let counter = 1;
+
+ while (offset < length) {
+ const input = new Uint8Array(previous.length + infoBytes.length + 1);
+ input.set(previous, 0);
+ input.set(infoBytes, previous.length);
+ input[input.length - 1] = counter & 0xff;
+ previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input)));
+ const copyLen = Math.min(previous.length, length - offset);
+ result.set(previous.slice(0, copyLen), offset);
+ offset += copyLen;
+ counter += 1;
+ }
+
+ return result;
+}
+
+async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise {
+ const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
+ return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
+}
+
+async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise {
+ const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']);
+ return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
+}
+
+async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise {
+ const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']);
+ return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
+}
+
+export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise {
+ const iv = crypto.getRandomValues(new Uint8Array(16));
+ const cipher = await encryptAesCbc(data, encKey, iv);
+ const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
+ return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
+}
+
+function parseCipherString(s: string): { type: number; iv: Uint8Array; ct: Uint8Array; mac: Uint8Array | null } {
+ if (!s || typeof s !== 'string') throw new Error('invalid encrypted string');
+ const p = s.indexOf('.');
+ if (p <= 0) throw new Error('invalid encrypted string');
+ const type = Number(s.slice(0, p));
+ const body = s.slice(p + 1);
+ const parts = body.split('|');
+ if (type === 2 && parts.length === 3) {
+ return { type: 2, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: base64ToBytes(parts[2]) };
+ }
+ if ((type === 0 || type === 1 || type === 4) && parts.length >= 2) {
+ return { type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null };
+ }
+ throw new Error('unsupported enc type');
+}
+
+export async function decryptBw(cipherString: string, encKey: Uint8Array, macKey?: Uint8Array): Promise {
+ const parsed = parseCipherString(cipherString);
+ if (parsed.type === 2 && macKey && parsed.mac) {
+ const expected = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct));
+ if (bytesToBase64(expected) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch');
+ }
+ return decryptAesCbc(parsed.ct, encKey, parsed.iv);
+}
+
+export async function decryptStr(cipherString: string | null | undefined, encKey: Uint8Array, macKey?: Uint8Array): Promise {
+ if (!cipherString || typeof cipherString !== 'string') return '';
+ const plain = await decryptBw(cipherString, encKey, macKey);
+ return new TextDecoder().decode(plain);
+}
+
+export function extractTotpSecret(raw: string): string {
+ if (!raw) return '';
+ const s = raw.trim();
+ if (!s) return '';
+ if (/^otpauth:\/\//i.test(s)) {
+ try {
+ const u = new URL(s);
+ return (u.searchParams.get('secret') || '').toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
+ } catch {
+ return '';
+ }
+ }
+ return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
+}
+
+function base32ToBytes(input: string): Uint8Array {
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+ const clean = input.toUpperCase().replace(/[^A-Z2-7]/g, '');
+ let bits = 0;
+ let value = 0;
+ const out: number[] = [];
+ for (let i = 0; i < clean.length; i += 1) {
+ const idx = alphabet.indexOf(clean.charAt(i));
+ if (idx < 0) continue;
+ value = (value << 5) | idx;
+ bits += 5;
+ if (bits >= 8) {
+ out.push((value >>> (bits - 8)) & 0xff);
+ bits -= 8;
+ }
+ }
+ return new Uint8Array(out);
+}
+
+export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
+ const secret = extractTotpSecret(rawSecret);
+ if (!secret) return null;
+ const keyBytes = base32ToBytes(secret);
+ if (!keyBytes.length) return null;
+ const step = 30;
+ const epoch = Math.floor(Date.now() / 1000);
+ const counter = Math.floor(epoch / step);
+ const remain = step - (epoch % step);
+
+ const message = new Uint8Array(8);
+ let c = counter;
+ for (let i = 7; i >= 0; i -= 1) {
+ message[i] = c & 0xff;
+ c = Math.floor(c / 256);
+ }
+ const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
+ const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
+ const offset = hs[hs.length - 1] & 0x0f;
+ const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
+ const code = (bin % 1000000).toString().padStart(6, '0');
+ return { code, remain };
+}
diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts
new file mode 100644
index 0000000..201e5d4
--- /dev/null
+++ b/webapp/src/lib/types.ts
@@ -0,0 +1,222 @@
+export type AppPhase = 'loading' | 'register' | 'login' | 'locked' | 'app';
+
+export interface SessionState {
+ accessToken: string;
+ refreshToken: string;
+ email: string;
+ symEncKey?: string;
+ symMacKey?: string;
+}
+
+export interface Profile {
+ id: string;
+ email: string;
+ name: string;
+ key: string;
+ role: 'admin' | 'user';
+ [k: string]: unknown;
+}
+
+export interface Folder {
+ id: string;
+ name: string;
+ decName?: string;
+}
+
+export interface CipherLoginUri {
+ uri?: string | null;
+ decUri?: string;
+}
+
+export interface CipherLogin {
+ username?: string | null;
+ password?: string | null;
+ totp?: string | null;
+ uris?: CipherLoginUri[] | null;
+ decUsername?: string;
+ decPassword?: string;
+ decTotp?: string;
+}
+
+export interface CipherCard {
+ cardholderName?: string | null;
+ number?: string | null;
+ brand?: string | null;
+ expMonth?: string | null;
+ expYear?: string | null;
+ code?: string | null;
+ decCardholderName?: string;
+ decNumber?: string;
+ decBrand?: string;
+ decExpMonth?: string;
+ decExpYear?: string;
+ decCode?: string;
+}
+
+export interface CipherIdentity {
+ title?: string | null;
+ firstName?: string | null;
+ middleName?: string | null;
+ lastName?: string | null;
+ username?: string | null;
+ company?: string | null;
+ ssn?: string | null;
+ passportNumber?: string | null;
+ licenseNumber?: string | null;
+ email?: string | null;
+ phone?: string | null;
+ address1?: string | null;
+ address2?: string | null;
+ address3?: string | null;
+ city?: string | null;
+ state?: string | null;
+ postalCode?: string | null;
+ country?: string | null;
+ decTitle?: string;
+ decFirstName?: string;
+ decMiddleName?: string;
+ decLastName?: string;
+ decUsername?: string;
+ decCompany?: string;
+ decSsn?: string;
+ decPassportNumber?: string;
+ decLicenseNumber?: string;
+ decEmail?: string;
+ decPhone?: string;
+ decAddress1?: string;
+ decAddress2?: string;
+ decAddress3?: string;
+ decCity?: string;
+ decState?: string;
+ decPostalCode?: string;
+ decCountry?: string;
+}
+
+export interface CipherSshKey {
+ privateKey?: string | null;
+ publicKey?: string | null;
+ fingerprint?: string | null;
+ decPrivateKey?: string;
+ decPublicKey?: string;
+ decFingerprint?: string;
+}
+
+export interface CipherField {
+ type?: number | string | null;
+ name?: string | null;
+ value?: string | null;
+ decName?: string;
+ decValue?: string;
+}
+
+export interface Cipher {
+ id: string;
+ type: number;
+ folderId?: string | null;
+ favorite?: boolean;
+ reprompt?: number;
+ name?: string | null;
+ notes?: string | null;
+ key?: string | null;
+ login?: CipherLogin | null;
+ card?: CipherCard | null;
+ identity?: CipherIdentity | null;
+ sshKey?: CipherSshKey | null;
+ fields?: CipherField[] | null;
+ decName?: string;
+ decNotes?: string;
+}
+
+export type CustomFieldType = 0 | 1 | 2 | 3;
+
+export interface VaultDraftField {
+ type: CustomFieldType;
+ label: string;
+ value: string;
+}
+
+export interface VaultDraft {
+ id?: string;
+ type: number;
+ name: string;
+ folderId: string;
+ notes: string;
+ reprompt: boolean;
+ loginUsername: string;
+ loginPassword: string;
+ loginTotp: string;
+ loginUris: string[];
+ cardholderName: string;
+ cardNumber: string;
+ cardBrand: string;
+ cardExpMonth: string;
+ cardExpYear: string;
+ cardCode: string;
+ identTitle: string;
+ identFirstName: string;
+ identMiddleName: string;
+ identLastName: string;
+ identUsername: string;
+ identCompany: string;
+ identSsn: string;
+ identPassportNumber: string;
+ identLicenseNumber: string;
+ identEmail: string;
+ identPhone: string;
+ identAddress1: string;
+ identAddress2: string;
+ identAddress3: string;
+ identCity: string;
+ identState: string;
+ identPostalCode: string;
+ identCountry: string;
+ sshPrivateKey: string;
+ sshPublicKey: string;
+ sshFingerprint: string;
+ customFields: VaultDraftField[];
+}
+
+export interface ListResponse {
+ object: 'list';
+ data: T[];
+}
+
+export interface SetupStatusResponse {
+ registered: boolean;
+}
+
+export interface WebConfigResponse {
+ defaultKdfIterations?: number;
+}
+
+export interface TokenSuccess {
+ access_token: string;
+ refresh_token: string;
+}
+
+export interface TokenError {
+ error?: string;
+ error_description?: string;
+ TwoFactorProviders?: unknown;
+}
+
+export interface ToastMessage {
+ id: string;
+ type: 'success' | 'error';
+ text: string;
+}
+
+export interface AdminUser {
+ id: string;
+ email: string;
+ name?: string;
+ role: string;
+ status: string;
+}
+
+export interface AdminInvite {
+ code: string;
+ inviteLink?: string;
+ status: string;
+ expiresAt?: string;
+}
diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx
new file mode 100644
index 0000000..2a0c941
--- /dev/null
+++ b/webapp/src/main.tsx
@@ -0,0 +1,20 @@
+import { render } from 'preact';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import App from './App';
+import './styles.css';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
+
+render(
+
+
+ ,
+ document.getElementById('root')!
+);
diff --git a/webapp/src/styles.css b/webapp/src/styles.css
new file mode 100644
index 0000000..8b4a895
--- /dev/null
+++ b/webapp/src/styles.css
@@ -0,0 +1,700 @@
+:root {
+ --bg: #f3f5f8;
+ --panel: #ffffff;
+ --line: #d7dde6;
+ --text: #0f172a;
+ --muted: #667085;
+ --primary: #2563eb;
+ --primary-hover: #1d4ed8;
+ --danger: #e11d48;
+ --danger-hover: #be123c;
+ --radius: 12px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ color: var(--text);
+ background: var(--bg);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+}
+
+.loading-screen {
+ height: 100%;
+ display: grid;
+ place-items: center;
+ color: var(--muted);
+ font-size: 18px;
+}
+
+.auth-page {
+ min-height: 100%;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+}
+
+.auth-card {
+ width: min(640px, 100%);
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: 0 10px 32px rgba(15, 23, 42, 0.08);
+ padding: 28px;
+}
+
+.auth-card h1 {
+ margin: 0 0 4px 0;
+ text-align: center;
+}
+
+.muted {
+ margin: 0 0 16px 0;
+ text-align: center;
+ color: var(--muted);
+}
+
+.field {
+ display: block;
+ margin-bottom: 14px;
+}
+
+.field > span {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.input {
+ width: 100%;
+ height: 48px;
+ border: 1px solid #3f5b9e;
+ border-radius: 10px;
+ padding: 10px 12px;
+ font-size: 16px;
+ outline: none;
+ background: #fff;
+}
+
+.textarea {
+ min-height: 110px;
+ height: auto;
+ resize: vertical;
+}
+
+.input:focus {
+ border-color: #2f5fd8;
+}
+
+.password-wrap {
+ position: relative;
+}
+
+.password-wrap .input {
+ padding-right: 88px;
+}
+
+.eye-btn {
+ position: absolute;
+ right: 42px;
+ bottom: 9px;
+ width: 30px;
+ height: 30px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+}
+
+.btn {
+ height: 42px;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ padding: 0 16px;
+ font-size: 15px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.btn.full {
+ width: 100%;
+ height: 50px;
+ font-size: 22px;
+}
+
+.btn.small {
+ height: 34px;
+ font-size: 14px;
+}
+
+.btn-primary {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: #fff;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ border-color: var(--primary-hover);
+}
+
+.btn-secondary {
+ background: #fff;
+ border-color: var(--primary);
+ color: var(--primary);
+}
+
+.btn-secondary:hover {
+ background: #eff5ff;
+}
+
+.btn-danger {
+ background: #fff;
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
+.btn-danger:hover {
+ background: #fff1f2;
+}
+
+.or {
+ text-align: center;
+ margin: 10px 0;
+ color: #334155;
+}
+
+.app-shell {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.topbar {
+ height: 64px;
+ background: var(--primary);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+}
+
+.brand {
+ font-size: 20px;
+ font-weight: 800;
+}
+
+.nav {
+ display: flex;
+ gap: 8px;
+}
+
+.nav-link {
+ color: rgba(255, 255, 255, 0.85);
+ text-decoration: none;
+ padding: 8px 14px;
+ border-radius: 10px;
+ font-weight: 600;
+}
+
+.nav-link.active,
+.nav-link:hover {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.16);
+}
+
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.user-email {
+ font-size: 13px;
+ opacity: 0.9;
+}
+
+.content {
+ flex: 1;
+ padding: 14px;
+ overflow: auto;
+ width: min(1540px, 100%);
+ margin: 0 auto;
+}
+
+.vault-grid {
+ display: grid;
+ grid-template-columns: 280px minmax(360px, 43%) 1fr;
+ gap: 12px;
+ height: calc(100vh - 64px - 28px);
+}
+
+.sidebar,
+.list-panel,
+.card {
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+}
+
+.sidebar {
+ padding: 10px;
+ overflow: auto;
+}
+
+.sidebar-block {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 10px;
+ margin-bottom: 10px;
+}
+
+.sidebar-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: #475467;
+ margin-bottom: 8px;
+}
+
+.search-input {
+ width: 100%;
+ height: 42px;
+ border: 1px solid var(--primary);
+ border-radius: 10px;
+ padding: 0 12px;
+}
+
+.tree-btn {
+ width: 100%;
+ border: none;
+ background: transparent;
+ text-align: left;
+ border-radius: 8px;
+ padding: 8px 10px;
+ margin-bottom: 4px;
+ cursor: pointer;
+}
+
+.tree-btn.active {
+ background: #eef4ff;
+ color: #175ddc;
+ font-weight: 700;
+}
+
+.list-col {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.toolbar {
+ margin-bottom: 8px;
+}
+
+.list-panel {
+ overflow: auto;
+ min-height: 0;
+}
+
+.list-item {
+ width: 100%;
+ background: #fff;
+ border-bottom: 1px solid var(--line);
+ padding: 12px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.list-item:hover {
+ background: #f8fbff;
+}
+
+.list-item.active {
+ background: #edf4ff;
+}
+
+.row-check {
+ width: 16px;
+ height: 16px;
+}
+
+.row-main {
+ flex: 1;
+ border: none;
+ background: transparent;
+ padding: 0;
+ display: flex;
+ gap: 10px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.list-icon-wrap {
+ width: 24px;
+ height: 24px;
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+}
+
+.list-icon {
+ width: 24px;
+ height: 24px;
+ border-radius: 6px;
+}
+
+.list-icon-fallback {
+ font-size: 20px;
+}
+
+.list-text {
+ min-width: 0;
+}
+
+.list-title {
+ display: block;
+ color: #175ddc;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.list-sub {
+ display: block;
+ color: #64748b;
+ margin-top: 4px;
+}
+
+.detail-col {
+ overflow: auto;
+}
+
+.card {
+ padding: 14px 16px;
+ margin-bottom: 10px;
+}
+
+.card h4 {
+ margin-top: 0;
+ margin-bottom: 12px;
+}
+
+.detail-title {
+ margin: 0;
+}
+
+.detail-sub {
+ color: #667085;
+ margin-top: 8px;
+}
+
+.kv-line {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ border-bottom: 1px solid #ecf0f5;
+ padding: 10px 0;
+}
+
+.kv-line:last-child {
+ border-bottom: none;
+}
+
+.kv-line > span {
+ color: #64748b;
+}
+
+.notes {
+ white-space: pre-wrap;
+ color: #334155;
+ min-height: 48px;
+}
+
+.empty {
+ color: #667085;
+ display: grid;
+ place-items: center;
+ min-height: 120px;
+}
+
+.stack {
+ display: grid;
+ gap: 12px;
+}
+
+.field-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.totp-grid {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.totp-qr {
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: #fff;
+ display: grid;
+ place-items: center;
+ min-height: 220px;
+ padding: 8px;
+}
+
+.totp-qr svg {
+ width: 180px;
+ height: 180px;
+}
+
+.section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.create-menu-wrap {
+ position: relative;
+}
+
+.create-menu {
+ position: absolute;
+ left: 0;
+ top: calc(100% + 6px);
+ width: 220px;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
+ overflow: hidden;
+ z-index: 20;
+}
+
+.create-menu-item {
+ width: 100%;
+ border: none;
+ background: #fff;
+ text-align: left;
+ padding: 11px 12px;
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.create-menu-item:hover {
+ background: #f1f5f9;
+}
+
+.uri-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.field-type-pill {
+ align-self: center;
+ height: 34px;
+ line-height: 34px;
+ border-radius: 999px;
+ background: #eef4ff;
+ color: #175ddc;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 0 10px;
+}
+
+.detail-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 12px 0;
+}
+
+.local-error {
+ margin-top: 10px;
+ color: #b42318;
+ font-weight: 600;
+}
+
+.kv-line strong {
+ overflow-wrap: anywhere;
+}
+
+.check-line {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ color: #334155;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table th,
+.table td {
+ text-align: left;
+ border-bottom: 1px solid var(--line);
+ padding: 10px 8px;
+ font-size: 14px;
+}
+
+.table th {
+ color: #667085;
+}
+
+.input.small {
+ width: 120px;
+}
+
+.dialog-mask {
+ position: fixed;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.5);
+ display: grid;
+ place-items: center;
+ z-index: 1200;
+ padding: 20px;
+}
+
+.dialog-card {
+ width: min(460px, 100%);
+ background: #fff;
+ border-radius: 20px;
+ border: 1px solid var(--line);
+ box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
+ padding: 20px;
+ text-align: center;
+}
+
+.dialog-card .field {
+ text-align: left;
+}
+
+.dialog-icon {
+ font-size: 34px;
+ color: #f59e0b;
+}
+
+.dialog-title {
+ margin: 6px 0;
+ font-size: 30px;
+}
+
+.dialog-message {
+ color: #475467;
+ margin-bottom: 10px;
+}
+
+.dialog-btn {
+ width: 100%;
+ height: 50px;
+ font-size: 20px;
+ margin-top: 8px;
+}
+
+.toast-stack {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 1400;
+ width: min(420px, calc(100vw - 20px));
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 10px;
+}
+
+.toast-item {
+ position: relative;
+ border-radius: 10px;
+ border: 1px solid #bbdfc6;
+ background: #dff4e5;
+ color: #0f5132;
+ padding: 12px 14px;
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
+ overflow: hidden;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.toast-item.error {
+ border-color: #f2b8c1;
+ background: #fde7eb;
+ color: #9f1239;
+}
+
+.toast-text {
+ font-weight: 700;
+ padding-right: 10px;
+}
+
+.toast-close {
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 20px;
+ color: inherit;
+}
+
+.toast-progress {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ height: 3px;
+ background: rgba(15, 23, 42, 0.2);
+ animation: toast-life 4.5s linear forwards;
+}
+
+@keyframes toast-life {
+ from {
+ transform: scaleX(1);
+ transform-origin: left center;
+ }
+ to {
+ transform: scaleX(0);
+ transform-origin: left center;
+ }
+}
+
+@media (max-width: 1180px) {
+ .vault-grid {
+ grid-template-columns: 1fr;
+ height: auto;
+ }
+ .sidebar {
+ max-height: 280px;
+ }
+ .totp-grid,
+ .field-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .uri-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts
new file mode 100644
index 0000000..049a086
--- /dev/null
+++ b/webapp/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+declare module 'qrcode-generator' {
+ interface QrCode {
+ addData(data: string): void;
+ make(): void;
+ createSvgTag(options?: { scalable?: boolean; margin?: number }): string;
+ }
+ export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode;
+}
diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json
new file mode 100644
index 0000000..d1744c5
--- /dev/null
+++ b/webapp/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "strict": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "resolveJsonModule": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*", "vite.config.ts"]
+}
diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts
new file mode 100644
index 0000000..9023388
--- /dev/null
+++ b/webapp/vite.config.ts
@@ -0,0 +1,35 @@
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+import preact from '@preact/preset-vite';
+import { defineConfig } from 'vite';
+
+const rootDir = fileURLToPath(new URL('.', import.meta.url));
+
+export default defineConfig({
+ root: rootDir,
+ plugins: [preact()],
+ resolve: {
+ alias: {
+ '@': path.resolve(rootDir, 'src'),
+ },
+ },
+ build: {
+ outDir: path.resolve(rootDir, '../public'),
+ emptyOutDir: false,
+ sourcemap: true,
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': 'http://127.0.0.1:8787',
+ '/identity': 'http://127.0.0.1:8787',
+ '/setup': 'http://127.0.0.1:8787',
+ '/icons': 'http://127.0.0.1:8787',
+ '/config': 'http://127.0.0.1:8787',
+ '/notifications': 'http://127.0.0.1:8787',
+ '/.well-known': 'http://127.0.0.1:8787',
+ '/favicon.ico': 'http://127.0.0.1:8787',
+ '/favicon.svg': 'http://127.0.0.1:8787',
+ },
+ },
+});