diff --git a/package.json b/package.json
index 05221d9b..84a49432 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"cmdk": "^1.0.0",
"crypto-random-string": "^5.0.0",
"immer": "^10.1.1",
+ "js-cookie": "^3.0.5",
"lucide-react": "^0.363.0",
"mapbox-gl": "^3.6.0",
"maplibre-gl": "4.1.2",
@@ -70,6 +71,7 @@
"@rsbuild/core": "^1.0.10",
"@rsbuild/plugin-react": "^1.0.3",
"@types/chrome": "^0.0.263",
+ "@types/js-cookie": "^3.0.6",
"@types/node": "^20.14.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9edbfd5..79d6d5ff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,9 @@ importers:
immer:
specifier: ^10.1.1
version: 10.1.1
+ js-cookie:
+ specifier: ^3.0.5
+ version: 3.0.5
lucide-react:
specifier: ^0.363.0
version: 0.363.0(react@18.3.1)
@@ -127,7 +130,7 @@ importers:
version: 3.0.6(react@18.3.1)
vite-plugin-node-polyfills:
specifier: ^0.22.0
- version: 0.22.0(rollup@4.24.0)(vite@5.3.6(@types/node@20.14.9))
+ version: 0.22.0(rollup@4.29.1)(vite@5.3.6(@types/node@20.14.9))
zustand:
specifier: 4.5.2
version: 4.5.2(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1)
@@ -147,6 +150,9 @@ importers:
'@types/chrome':
specifier: ^0.0.263
version: 0.0.263
+ '@types/js-cookie':
+ specifier: ^3.0.6
+ version: 3.0.6
'@types/node':
specifier: ^20.14.9
version: 20.14.9
@@ -173,7 +179,7 @@ importers:
version: 8.4.38
rollup-plugin-visualizer:
specifier: ^5.12.0
- version: 5.12.0(rollup@4.24.0)
+ version: 5.12.0(rollup@4.29.1)
tailwindcss:
specifier: ^3.4.4
version: 3.4.4
@@ -1165,83 +1171,98 @@ packages:
rollup:
optional: true
- '@rollup/rollup-android-arm-eabi@4.24.0':
- resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==}
+ '@rollup/rollup-android-arm-eabi@4.29.1':
+ resolution: {integrity: sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==}
cpu: [arm]
os: [android]
- '@rollup/rollup-android-arm64@4.24.0':
- resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==}
+ '@rollup/rollup-android-arm64@4.29.1':
+ resolution: {integrity: sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.24.0':
- resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==}
+ '@rollup/rollup-darwin-arm64@4.29.1':
+ resolution: {integrity: sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.24.0':
- resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==}
+ '@rollup/rollup-darwin-x64@4.29.1':
+ resolution: {integrity: sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-linux-arm-gnueabihf@4.24.0':
- resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==}
+ '@rollup/rollup-freebsd-arm64@4.29.1':
+ resolution: {integrity: sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.29.1':
+ resolution: {integrity: sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.29.1':
+ resolution: {integrity: sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.24.0':
- resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==}
+ '@rollup/rollup-linux-arm-musleabihf@4.29.1':
+ resolution: {integrity: sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm64-gnu@4.24.0':
- resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==}
+ '@rollup/rollup-linux-arm64-gnu@4.29.1':
+ resolution: {integrity: sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-arm64-musl@4.24.0':
- resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==}
+ '@rollup/rollup-linux-arm64-musl@4.29.1':
+ resolution: {integrity: sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
- resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==}
+ '@rollup/rollup-linux-loongarch64-gnu@4.29.1':
+ resolution: {integrity: sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.29.1':
+ resolution: {integrity: sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==}
cpu: [ppc64]
os: [linux]
- '@rollup/rollup-linux-riscv64-gnu@4.24.0':
- resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==}
+ '@rollup/rollup-linux-riscv64-gnu@4.29.1':
+ resolution: {integrity: sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-s390x-gnu@4.24.0':
- resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==}
+ '@rollup/rollup-linux-s390x-gnu@4.29.1':
+ resolution: {integrity: sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==}
cpu: [s390x]
os: [linux]
- '@rollup/rollup-linux-x64-gnu@4.24.0':
- resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==}
+ '@rollup/rollup-linux-x64-gnu@4.29.1':
+ resolution: {integrity: sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-linux-x64-musl@4.24.0':
- resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==}
+ '@rollup/rollup-linux-x64-musl@4.29.1':
+ resolution: {integrity: sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-win32-arm64-msvc@4.24.0':
- resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==}
+ '@rollup/rollup-win32-arm64-msvc@4.29.1':
+ resolution: {integrity: sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.24.0':
- resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==}
+ '@rollup/rollup-win32-ia32-msvc@4.29.1':
+ resolution: {integrity: sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==}
cpu: [ia32]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.24.0':
- resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==}
+ '@rollup/rollup-win32-x64-msvc@4.29.1':
+ resolution: {integrity: sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==}
cpu: [x64]
os: [win32]
@@ -1690,6 +1711,9 @@ packages:
'@types/har-format@1.2.15':
resolution: {integrity: sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==}
+ '@types/js-cookie@3.0.6':
+ resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
+
'@types/mapbox-gl@3.1.0':
resolution: {integrity: sha512-hI6cQDjw1bkJw7MC/eHMqq5TWUamLwsujnUUeiIX2KDRjxRNSYMjnHz07+LATz9I9XIsKumOtUz4gRYnZOJ/FA==}
@@ -1864,8 +1888,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
- caniuse-lite@1.0.30001638:
- resolution: {integrity: sha512-5SuJUJ7cZnhPpeLHaH0c/HPAnAHZvS6ElWyHK9GSIbVOQABLzowiI2pjmpvZ1WEbkyz46iFd4UXlOHR5SqgfMQ==}
+ caniuse-lite@1.0.30001690:
+ resolution: {integrity: sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==}
cheap-ruler@4.0.0:
resolution: {integrity: sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==}
@@ -2417,6 +2441,10 @@ packages:
resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==}
hasBin: true
+ js-cookie@3.0.5:
+ resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
+ engines: {node: '>=14'}
+
js-sha3@0.8.0:
resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==}
@@ -2923,8 +2951,8 @@ packages:
rollup:
optional: true
- rollup@4.24.0:
- resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==}
+ rollup@4.29.1:
+ resolution: {integrity: sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -4258,68 +4286,77 @@ snapshots:
'@radix-ui/rect@1.1.0': {}
- '@rollup/plugin-inject@5.0.5(rollup@4.24.0)':
+ '@rollup/plugin-inject@5.0.5(rollup@4.29.1)':
dependencies:
- '@rollup/pluginutils': 5.1.0(rollup@4.24.0)
+ '@rollup/pluginutils': 5.1.0(rollup@4.29.1)
estree-walker: 2.0.2
magic-string: 0.30.11
optionalDependencies:
- rollup: 4.24.0
+ rollup: 4.29.1
- '@rollup/pluginutils@5.1.0(rollup@4.24.0)':
+ '@rollup/pluginutils@5.1.0(rollup@4.29.1)':
dependencies:
'@types/estree': 1.0.5
estree-walker: 2.0.2
picomatch: 2.3.1
optionalDependencies:
- rollup: 4.24.0
+ rollup: 4.29.1
+
+ '@rollup/rollup-android-arm-eabi@4.29.1':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.29.1':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.29.1':
+ optional: true
- '@rollup/rollup-android-arm-eabi@4.24.0':
+ '@rollup/rollup-darwin-x64@4.29.1':
optional: true
- '@rollup/rollup-android-arm64@4.24.0':
+ '@rollup/rollup-freebsd-arm64@4.29.1':
optional: true
- '@rollup/rollup-darwin-arm64@4.24.0':
+ '@rollup/rollup-freebsd-x64@4.29.1':
optional: true
- '@rollup/rollup-darwin-x64@4.24.0':
+ '@rollup/rollup-linux-arm-gnueabihf@4.29.1':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.24.0':
+ '@rollup/rollup-linux-arm-musleabihf@4.29.1':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.24.0':
+ '@rollup/rollup-linux-arm64-gnu@4.29.1':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.24.0':
+ '@rollup/rollup-linux-arm64-musl@4.29.1':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.24.0':
+ '@rollup/rollup-linux-loongarch64-gnu@4.29.1':
optional: true
- '@rollup/rollup-linux-powerpc64le-gnu@4.24.0':
+ '@rollup/rollup-linux-powerpc64le-gnu@4.29.1':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.24.0':
+ '@rollup/rollup-linux-riscv64-gnu@4.29.1':
optional: true
- '@rollup/rollup-linux-s390x-gnu@4.24.0':
+ '@rollup/rollup-linux-s390x-gnu@4.29.1':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.24.0':
+ '@rollup/rollup-linux-x64-gnu@4.29.1':
optional: true
- '@rollup/rollup-linux-x64-musl@4.24.0':
+ '@rollup/rollup-linux-x64-musl@4.29.1':
optional: true
- '@rollup/rollup-win32-arm64-msvc@4.24.0':
+ '@rollup/rollup-win32-arm64-msvc@4.29.1':
optional: true
- '@rollup/rollup-win32-ia32-msvc@4.24.0':
+ '@rollup/rollup-win32-ia32-msvc@4.29.1':
optional: true
- '@rollup/rollup-win32-x64-msvc@4.24.0':
+ '@rollup/rollup-win32-x64-msvc@4.29.1':
optional: true
'@rsbuild/core@1.0.10':
@@ -4381,7 +4418,7 @@ snapshots:
'@module-federation/runtime-tools': 0.5.1
'@rspack/binding': 1.0.8
'@rspack/lite-tapable': 1.0.1
- caniuse-lite: 1.0.30001638
+ caniuse-lite: 1.0.30001690
optionalDependencies:
'@swc/helpers': 0.5.13
@@ -5255,6 +5292,8 @@ snapshots:
'@types/har-format@1.2.15': {}
+ '@types/js-cookie@3.0.6': {}
+
'@types/mapbox-gl@3.1.0':
dependencies:
'@types/geojson': 7946.0.14
@@ -5343,7 +5382,7 @@ snapshots:
autoprefixer@10.4.19(postcss@8.4.38):
dependencies:
browserslist: 4.23.1
- caniuse-lite: 1.0.30001638
+ caniuse-lite: 1.0.30001690
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.0.1
@@ -5424,7 +5463,7 @@ snapshots:
browserslist@4.23.1:
dependencies:
- caniuse-lite: 1.0.30001638
+ caniuse-lite: 1.0.30001690
electron-to-chromium: 1.4.812
node-releases: 2.0.14
update-browserslist-db: 1.0.16(browserslist@4.23.1)
@@ -5459,7 +5498,7 @@ snapshots:
camelcase-css@2.0.1: {}
- caniuse-lite@1.0.30001638: {}
+ caniuse-lite@1.0.30001690: {}
cheap-ruler@4.0.0: {}
@@ -6075,6 +6114,8 @@ snapshots:
jiti@1.21.6: {}
+ js-cookie@3.0.5: {}
+
js-sha3@0.8.0: {}
js-tokens@4.0.0: {}
@@ -6620,35 +6661,38 @@ snapshots:
robust-predicates@3.0.2: {}
- rollup-plugin-visualizer@5.12.0(rollup@4.24.0):
+ rollup-plugin-visualizer@5.12.0(rollup@4.29.1):
dependencies:
open: 8.4.2
picomatch: 2.3.1
source-map: 0.7.4
yargs: 17.7.2
optionalDependencies:
- rollup: 4.24.0
+ rollup: 4.29.1
- rollup@4.24.0:
+ rollup@4.29.1:
dependencies:
'@types/estree': 1.0.6
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.24.0
- '@rollup/rollup-android-arm64': 4.24.0
- '@rollup/rollup-darwin-arm64': 4.24.0
- '@rollup/rollup-darwin-x64': 4.24.0
- '@rollup/rollup-linux-arm-gnueabihf': 4.24.0
- '@rollup/rollup-linux-arm-musleabihf': 4.24.0
- '@rollup/rollup-linux-arm64-gnu': 4.24.0
- '@rollup/rollup-linux-arm64-musl': 4.24.0
- '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0
- '@rollup/rollup-linux-riscv64-gnu': 4.24.0
- '@rollup/rollup-linux-s390x-gnu': 4.24.0
- '@rollup/rollup-linux-x64-gnu': 4.24.0
- '@rollup/rollup-linux-x64-musl': 4.24.0
- '@rollup/rollup-win32-arm64-msvc': 4.24.0
- '@rollup/rollup-win32-ia32-msvc': 4.24.0
- '@rollup/rollup-win32-x64-msvc': 4.24.0
+ '@rollup/rollup-android-arm-eabi': 4.29.1
+ '@rollup/rollup-android-arm64': 4.29.1
+ '@rollup/rollup-darwin-arm64': 4.29.1
+ '@rollup/rollup-darwin-x64': 4.29.1
+ '@rollup/rollup-freebsd-arm64': 4.29.1
+ '@rollup/rollup-freebsd-x64': 4.29.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.29.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.29.1
+ '@rollup/rollup-linux-arm64-gnu': 4.29.1
+ '@rollup/rollup-linux-arm64-musl': 4.29.1
+ '@rollup/rollup-linux-loongarch64-gnu': 4.29.1
+ '@rollup/rollup-linux-powerpc64le-gnu': 4.29.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.29.1
+ '@rollup/rollup-linux-s390x-gnu': 4.29.1
+ '@rollup/rollup-linux-x64-gnu': 4.29.1
+ '@rollup/rollup-linux-x64-musl': 4.29.1
+ '@rollup/rollup-win32-arm64-msvc': 4.29.1
+ '@rollup/rollup-win32-ia32-msvc': 4.29.1
+ '@rollup/rollup-win32-x64-msvc': 4.29.1
fsevents: 2.3.3
run-parallel@1.2.0:
@@ -6986,9 +7030,9 @@ snapshots:
validator@13.12.0: {}
- vite-plugin-node-polyfills@0.22.0(rollup@4.24.0)(vite@5.3.6(@types/node@20.14.9)):
+ vite-plugin-node-polyfills@0.22.0(rollup@4.29.1)(vite@5.3.6(@types/node@20.14.9)):
dependencies:
- '@rollup/plugin-inject': 5.0.5(rollup@4.24.0)
+ '@rollup/plugin-inject': 5.0.5(rollup@4.29.1)
node-stdlib-browser: 1.2.0
vite: 5.3.6(@types/node@20.14.9)
transitivePeerDependencies:
@@ -6998,7 +7042,7 @@ snapshots:
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
- rollup: 4.24.0
+ rollup: 4.29.1
optionalDependencies:
'@types/node': 20.14.9
fsevents: 2.3.3
diff --git a/src/App.tsx b/src/App.tsx
index c936bc9c..8c15dc97 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,7 +2,7 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx";
import { CommandPalette } from "@components/CommandPalette.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx";
-import { DialogManager } from "@components/Dialog/DialogManager.tsx";
+import { DialogManager } from "@components/Dialog/DialogManager";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { Toaster } from "@components/Toaster.tsx";
import Footer from "@components/UI/Footer.tsx";
@@ -11,6 +11,7 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { MapProvider } from "react-map-gl";
+import { KeyBackupReminder } from "@components/KeyBackupReminder";
export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore();
@@ -37,6 +38,7 @@ export const App = (): JSX.Element => {
{device ? (
diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx
index 16e60120..afebdf5d 100644
--- a/src/components/Dialog/DialogManager.tsx
+++ b/src/components/Dialog/DialogManager.tsx
@@ -5,6 +5,7 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
+import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog";
export const DialogManager = (): JSX.Element => {
const { channels, config, dialog, setDialogOpen } = useDevice();
@@ -49,6 +50,12 @@ export const DialogManager = (): JSX.Element => {
setDialogOpen("nodeRemoval", open);
}}
/>
+ {
+ setDialogOpen("pkiBackup", open);
+ }}
+ />
>
);
};
diff --git a/src/components/Dialog/PKIBackupDialog.tsx b/src/components/Dialog/PKIBackupDialog.tsx
new file mode 100644
index 00000000..9b6c18d8
--- /dev/null
+++ b/src/components/Dialog/PKIBackupDialog.tsx
@@ -0,0 +1,134 @@
+import { useDevice } from "@app/core/stores/deviceStore";
+import { Button } from "@components/UI/Button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@components/UI/Dialog.tsx";
+import { fromByteArray } from "base64-js";
+import { DownloadIcon, PrinterIcon } from "lucide-react";
+import React from "react";
+
+export interface PkiBackupDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const PkiBackupDialog = ({
+ open,
+ onOpenChange,
+}: PkiBackupDialogProps) => {
+ const { config, setDialogOpen } = useDevice();
+ const privateKey = config.security?.privateKey;
+ const publicKey = config.security?.publicKey;
+
+ const decodeKeyData = React.useCallback(
+ (key: Uint8Array) => {
+ if (!key) return "";
+ return fromByteArray(key ?? new Uint8Array(0));
+ },
+ [],
+ );
+
+ const closeDialog = React.useCallback(() => {
+ setDialogOpen("pkiBackup", false);
+ }, [setDialogOpen]);
+
+ const renderPrintWindow = React.useCallback(() => {
+ if (!privateKey || !publicKey) return;
+
+ const printWindow = window.open("", "_blank");
+ if (printWindow) {
+ printWindow.document.write(`
+
+
+ === MESHTASTIC KEYS ===
+
+
+
+ === MESHTASTIC KEYS ===
+
+ Public Key:
+ ${decodeKeyData(publicKey)}
+ Private Key:
+ ${decodeKeyData(privateKey)}
+
+ === END OF KEYS ===
+
+
+ `);
+ printWindow.document.close();
+ printWindow.print();
+ closeDialog();
+ }
+ }, [decodeKeyData, privateKey, publicKey, closeDialog]);
+
+ const createDownloadKeyFile = React.useCallback(() => {
+ if (!privateKey || !publicKey) return;
+
+ const decodedPrivateKey = decodeKeyData(privateKey);
+ const decodedPublicKey = decodeKeyData(publicKey);
+
+ const formattedContent = [
+ "=== MESHTASTIC KEYS ===\n\n",
+ "Private Key:\n",
+ decodedPrivateKey,
+ "\n\nPublic Key:\n",
+ decodedPublicKey,
+ "\n\n=== END OF KEYS ===",
+ ].join("");
+
+ const blob = new Blob([formattedContent], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "meshtastic_keys.txt";
+ link.style.display = "none";
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ closeDialog();
+ URL.revokeObjectURL(url);
+ }, [decodeKeyData, privateKey, publicKey, closeDialog]);
+
+ return (
+
+ );
+};
diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx
index 09e97ff5..784086fb 100644
--- a/src/components/Form/FormPasswordGenerator.tsx
+++ b/src/components/Form/FormPasswordGenerator.tsx
@@ -7,6 +7,7 @@ import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler, MouseEventHandler } from "react";
import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form";
+import type { ButtonVariant } from "@components/UI/Button";
export interface PasswordGeneratorProps extends BaseFormBuilderProps {
type: "passwordGenerator";
@@ -15,7 +16,12 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps {
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
selectChange: (event: string) => void;
- buttonClick: MouseEventHandler;
+ actionButtons: {
+ text: string;
+ onClick: React.MouseEventHandler;
+ variant: ButtonVariant;
+ className?: string;
+ }[];
}
export function PasswordGenerator({
@@ -38,19 +44,18 @@ export function PasswordGenerator({
action={
field.hide
? {
- icon: passwordShown ? EyeOff : Eye,
- onClick: togglePasswordVisiblity,
- }
+ icon: passwordShown ? EyeOff : Eye,
+ onClick: togglePasswordVisiblity,
+ }
: undefined
}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
selectChange={field.selectChange}
- buttonClick={field.buttonClick}
value={value}
variant={field.validationText ? "invalid" : "default"}
- buttonText="Generate"
+ actionButtons={field.actionButtons}
{...field.properties}
{...rest}
disabled={disabled}
diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx
new file mode 100644
index 00000000..7463d85f
--- /dev/null
+++ b/src/components/KeyBackupReminder.tsx
@@ -0,0 +1,19 @@
+import { useBackupReminder } from "@app/core/hooks/useKeyBackupReminder";
+import { useDevice } from "@app/core/stores/deviceStore";
+
+export const KeyBackupReminder = (): JSX.Element => {
+ const { setDialogOpen } = useDevice();
+
+ useBackupReminder({
+ reminderInDays: 7,
+ message:
+ "We recommend backing up your key data regularly. Would you like to back up now?",
+ onAccept: () => setDialogOpen("pkiBackup", true),
+ enabled: true,
+ cookieOptions: {
+ secure: true,
+ sameSite: "strict",
+ },
+ });
+ return <>>;
+};
diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx
index e281b242..ef316c91 100644
--- a/src/components/PageComponents/Channel.tsx
+++ b/src/components/PageComponents/Channel.tsx
@@ -6,6 +6,7 @@ import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { useState } from "react";
+import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog";
export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel;
@@ -22,6 +23,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
channel?.settings?.psk.length ?? 16,
);
const [validationText, setValidationText] = useState();
+ const [preSharedDialogOpen, setPreSharedDialogOpen] = useState(false);
const onSubmit = (data: ChannelValidation) => {
const channel = new Protobuf.Channel.Channel({
@@ -46,7 +48,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
});
};
- const clickEvent = () => {
+ const preSharedKeyRegenerate = () => {
setPass(
btoa(
cryptoRandomString({
@@ -56,6 +58,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
),
);
setValidationText(undefined);
+ setPreSharedDialogOpen(false);
+ };
+
+ const preSharedClickEvent = () => {
+ setPreSharedDialogOpen(true);
};
const validatePass = (input: string, count: number) => {
@@ -79,132 +86,139 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
};
return (
-
- onSubmit={onSubmit}
- submitType="onSubmit"
- hasSubmitButton={true}
- defaultValues={{
- ...channel,
- ...{
- settings: {
- ...channel?.settings,
- psk: pass,
- positionEnabled:
- channel?.settings?.moduleSettings?.positionPrecision !==
- undefined &&
- channel?.settings?.moduleSettings?.positionPrecision > 0,
- preciseLocation:
- channel?.settings?.moduleSettings?.positionPrecision === 32,
- positionPrecision:
- channel?.settings?.moduleSettings?.positionPrecision === undefined
- ? 10
- : channel?.settings?.moduleSettings?.positionPrecision,
+ <>
+
+ onSubmit={onSubmit}
+ submitType="onSubmit"
+ hasSubmitButton={true}
+ defaultValues={{
+ ...channel,
+ ...{
+ settings: {
+ ...channel?.settings,
+ psk: pass,
+ positionEnabled:
+ channel?.settings?.moduleSettings?.positionPrecision !==
+ undefined &&
+ channel?.settings?.moduleSettings?.positionPrecision > 0,
+ preciseLocation:
+ channel?.settings?.moduleSettings?.positionPrecision === 32,
+ positionPrecision:
+ channel?.settings?.moduleSettings?.positionPrecision === undefined
+ ? 10
+ : channel?.settings?.moduleSettings?.positionPrecision,
+ },
},
- },
- }}
- fieldGroups={[
- {
- label: "Channel Settings",
- description: "Crypto, MQTT & misc settings",
- fields: [
- {
- type: "select",
- name: "role",
- label: "Role",
- disabled: channel.index === 0,
- description:
- "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
- properties: {
- enumValue:
- channel.index === 0
- ? { PRIMARY: 1 }
- : { DISABLED: 0, SECONDARY: 2 },
+ }}
+ fieldGroups={[
+ {
+ label: "Channel Settings",
+ description: "Crypto, MQTT & misc settings",
+ fields: [
+ {
+ type: "select",
+ name: "role",
+ label: "Role",
+ disabled: channel.index === 0,
+ description:
+ "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed",
+ properties: {
+ enumValue:
+ channel.index === 0
+ ? { PRIMARY: 1 }
+ : { DISABLED: 0, SECONDARY: 2 },
+ },
},
- },
- {
- type: "passwordGenerator",
- name: "settings.psk",
- label: "Pre-Shared Key",
- description: "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
- validationText: validationText,
- devicePSKBitCount: bitCount ?? 0,
- inputChange: inputChangeEvent,
- selectChange: selectChangeEvent,
- buttonClick: clickEvent,
- hide: true,
- properties: {
- value: pass,
+ {
+ type: "passwordGenerator",
+ name: "settings.psk",
+ label: "Pre-Shared Key",
+ description: "Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
+ validationText: validationText,
+ devicePSKBitCount: bitCount ?? 0,
+ inputChange: inputChangeEvent,
+ selectChange: selectChangeEvent,
+ actionButtons: [{ text: 'Generate', variant: 'success', onClick: preSharedClickEvent }],
+ hide: true,
+ properties: {
+ value: pass,
+ },
},
- },
- {
- type: "text",
- name: "settings.name",
- label: "Name",
- description:
- "A unique name for the channel <12 bytes, leave blank for default",
- },
- {
- type: "toggle",
- name: "settings.uplinkEnabled",
- label: "Uplink Enabled",
- description: "Send messages from the local mesh to MQTT",
- },
- {
- type: "toggle",
- name: "settings.downlinkEnabled",
- label: "Downlink Enabled",
- description: "Send messages from MQTT to the local mesh",
- },
- {
- type: "toggle",
- name: "settings.positionEnabled",
- label: "Allow Position Requests",
- description: "Send position to channel",
- },
- {
- type: "toggle",
- name: "settings.preciseLocation",
- label: "Precise Location",
- description: "Send precise location to channel",
- },
- {
- type: "select",
- name: "settings.positionPrecision",
- label: "Approximate Location",
- description:
- "If not sharing precise location, position shared on channel will be accurate within this distance",
- properties: {
- enumValue:
- config.display?.units === 0
- ? {
- "Within 23 km": 10,
- "Within 12 km": 11,
- "Within 5.8 km": 12,
- "Within 2.9 km": 13,
- "Within 1.5 km": 14,
- "Within 700 m": 15,
- "Within 350 m": 16,
- "Within 200 m": 17,
- "Within 90 m": 18,
- "Within 50 m": 19,
- }
- : {
- "Within 15 miles": 10,
- "Within 7.3 miles": 11,
- "Within 3.6 miles": 12,
- "Within 1.8 miles": 13,
- "Within 0.9 miles": 14,
- "Within 0.5 miles": 15,
- "Within 0.2 miles": 16,
- "Within 600 feet": 17,
- "Within 300 feet": 18,
- "Within 150 feet": 19,
- },
+ {
+ type: "text",
+ name: "settings.name",
+ label: "Name",
+ description:
+ "A unique name for the channel <12 bytes, leave blank for default",
},
- },
- ],
- },
- ]}
- />
+ {
+ type: "toggle",
+ name: "settings.uplinkEnabled",
+ label: "Uplink Enabled",
+ description: "Send messages from the local mesh to MQTT",
+ },
+ {
+ type: "toggle",
+ name: "settings.downlinkEnabled",
+ label: "Downlink Enabled",
+ description: "Send messages from MQTT to the local mesh",
+ },
+ {
+ type: "toggle",
+ name: "settings.positionEnabled",
+ label: "Allow Position Requests",
+ description: "Send position to channel",
+ },
+ {
+ type: "toggle",
+ name: "settings.preciseLocation",
+ label: "Precise Location",
+ description: "Send precise location to channel",
+ },
+ {
+ type: "select",
+ name: "settings.positionPrecision",
+ label: "Approximate Location",
+ description:
+ "If not sharing precise location, position shared on channel will be accurate within this distance",
+ properties: {
+ enumValue:
+ config.display?.units === 0
+ ? {
+ "Within 23 km": 10,
+ "Within 12 km": 11,
+ "Within 5.8 km": 12,
+ "Within 2.9 km": 13,
+ "Within 1.5 km": 14,
+ "Within 700 m": 15,
+ "Within 350 m": 16,
+ "Within 200 m": 17,
+ "Within 90 m": 18,
+ "Within 50 m": 19,
+ }
+ : {
+ "Within 15 miles": 10,
+ "Within 7.3 miles": 11,
+ "Within 3.6 miles": 12,
+ "Within 1.8 miles": 13,
+ "Within 0.9 miles": 14,
+ "Within 0.5 miles": 15,
+ "Within 0.2 miles": 16,
+ "Within 600 feet": 17,
+ "Within 300 feet": 18,
+ "Within 150 feet": 19,
+ },
+ },
+ },
+ ],
+ },
+ ]}
+ />
+ setPreSharedDialogOpen(false)}
+ onSubmit={() => preSharedKeyRegenerate()}
+ />
+ >
);
};
diff --git a/src/components/PageComponents/Config/Security.tsx b/src/components/PageComponents/Config/Security.tsx
index cf675751..d6e76cc9 100644
--- a/src/components/PageComponents/Config/Security.tsx
+++ b/src/components/PageComponents/Config/Security.tsx
@@ -12,7 +12,7 @@ import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
export const Security = (): JSX.Element => {
- const { config, nodes, hardware, setWorkingConfig } = useDevice();
+ const { config, nodes, hardware, setWorkingConfig, setDialogOpen } = useDevice();
const [privateKey, setPrivateKey] = useState(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
@@ -31,7 +31,7 @@ export const Security = (): JSX.Element => {
);
const [adminKeyValidationText, setAdminKeyValidationText] =
useState();
- const [dialogOpen, setDialogOpen] = useState(false);
+ const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState(false);
const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return;
@@ -71,9 +71,13 @@ export const Security = (): JSX.Element => {
};
const privateKeyClickEvent = () => {
- setDialogOpen(true);
+ setPrivateKeyDialogOpen(true);
};
+ const pkiBackupClickEvent = () => {
+ setDialogOpen("pkiBackup", true);
+ }
+
const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey();
const publicKey = getX25519PublicKey(privateKey);
@@ -86,7 +90,7 @@ export const Security = (): JSX.Element => {
setPrivateKeyValidationText,
);
- setDialogOpen(false);
+ setPrivateKeyDialogOpen(false);
};
const privateKeyInputChangeEvent = (
@@ -149,7 +153,18 @@ export const Security = (): JSX.Element => {
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
- buttonClick: privateKeyClickEvent,
+ actionButtons: [
+ {
+ text: "Generate",
+ onClick: privateKeyClickEvent,
+ variant: "success",
+ },
+ {
+ text: "Backup Key",
+ onClick: pkiBackupClickEvent,
+ variant: "subtle",
+ },
+ ],
properties: {
value: privateKey,
action: {
@@ -228,8 +243,8 @@ export const Security = (): JSX.Element => {
]}
/>
setDialogOpen(false)}
+ open={privateKeyDialogOpen}
+ onOpenChange={() => setPrivateKeyDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
/>
>
diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx
index cfe13a9d..f5531bba 100644
--- a/src/components/Toaster.tsx
+++ b/src/components/Toaster.tsx
@@ -1,5 +1,3 @@
-import { useToast } from "@core/hooks/useToast.ts";
-
import {
Toast,
ToastClose,
@@ -7,24 +5,25 @@ import {
ToastProvider,
ToastTitle,
ToastViewport,
-} from "@components/UI/Toast.tsx";
+} from "@components/UI/Toast";
+import { useToast } from "@core/hooks/useToast";
export function Toaster() {
const { toasts } = useToast();
return (
- {toasts.map(({ id, title, description, action, ...props }) => (
-
+ {toasts.map(({ id, title, description, action, duration, ...props }) => (
+
- {title && (
- {title}
- )}
- {description && (
-
- {description}
-
- )}
+ {title && {title}}
+ {description && {description}}
{action}
@@ -33,4 +32,4 @@ export function Toaster() {
);
-}
+}
\ No newline at end of file
diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx
index 39418551..e1b2d704 100644
--- a/src/components/UI/Button.tsx
+++ b/src/components/UI/Button.tsx
@@ -35,9 +35,11 @@ const buttonVariants = cva(
},
);
+export type ButtonVariant = VariantProps["variant"];
+
export interface ButtonProps
extends React.ButtonHTMLAttributes,
- VariantProps {}
+ VariantProps { }
const Button = React.forwardRef(
({ className, variant, size, ...props }, ref) => {
diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx
index 5e29ef49..b365d7c9 100644
--- a/src/components/UI/Generator.tsx
+++ b/src/components/UI/Generator.tsx
@@ -1,6 +1,6 @@
import * as React from "react";
-import { Button } from "@components/UI/Button.tsx";
+import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import {
Select,
@@ -16,11 +16,15 @@ export interface GeneratorProps extends React.BaseHTMLAttributes {
devicePSKBitCount?: number;
value: string;
variant: "default" | "invalid";
- buttonText?: string;
+ actionButtons: {
+ text: string;
+ onClick: React.MouseEventHandler;
+ variant: ButtonVariant;
+ className?: string;
+ }[];
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent) => void;
- buttonClick: React.MouseEventHandler;
action?: {
icon: LucideIcon;
onClick: () => void;
@@ -35,7 +39,7 @@ const Generator = React.forwardRef(
devicePSKBitCount,
variant,
value,
- buttonText,
+ actionButtons,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
@@ -44,7 +48,6 @@ const Generator = React.forwardRef(
],
selectChange,
inputChange,
- buttonClick,
action,
disabled,
...props
@@ -94,15 +97,21 @@ const Generator = React.forwardRef(
))}
-
+
+ {actionButtons?.map(({ text, onClick, variant, className }) => (
+
+ ))}
+
>
);
},
diff --git a/src/components/UI/Toast.tsx b/src/components/UI/Toast.tsx
index ca8dfc90..d40b294a 100644
--- a/src/components/UI/Toast.tsx
+++ b/src/components/UI/Toast.tsx
@@ -1,11 +1,11 @@
-import * as ToastPrimitives from "@radix-ui/react-toast";
-import { type VariantProps, cva } from "class-variance-authority";
-import { X } from "lucide-react";
-import * as React from "react";
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from 'lucide-react'
-import { cn } from "@core/utils/cn.ts";
+import { cn } from "@core/utils/cn"
-const ToastProvider = ToastPrimitives.Provider;
+const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef,
@@ -14,35 +14,34 @@ const ToastViewport = React.forwardRef<
-));
-ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
- "data-[swipe=move]:transition-none grow-1 group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full mt-4 data-[state=closed]:slide-out-to-right-full dark:border-slate-700 last:mt-0 sm:last:mt-4",
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
- default:
- "bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700",
+ default: "border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
destructive:
- "group destructive bg-red-600 text-white border-red-600 dark:border-red-600",
+ "group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50"
},
},
defaultVariants: {
variant: "default",
},
- },
-);
+ }
+)
const Toast = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef &
- VariantProps
+ VariantProps
>(({ className, variant, ...props }, ref) => {
return (
- );
-});
-Toast.displayName = ToastPrimitives.Root.displayName;
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef,
@@ -61,13 +60,13 @@ const ToastAction = React.forwardRef<
-));
-ToastAction.displayName = ToastPrimitives.Action.displayName;
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef,
@@ -76,16 +75,16 @@ const ToastClose = React.forwardRef<
-));
-ToastClose.displayName = ToastPrimitives.Close.displayName;
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef,
@@ -96,8 +95,8 @@ const ToastTitle = React.forwardRef<
className={cn("text-sm font-semibold", className)}
{...props}
/>
-));
-ToastTitle.displayName = ToastPrimitives.Title.displayName;
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef,
@@ -108,12 +107,12 @@ const ToastDescription = React.forwardRef<
className={cn("text-sm opacity-90", className)}
{...props}
/>
-));
-ToastDescription.displayName = ToastPrimitives.Description.displayName;
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
-type ToastProps = React.ComponentPropsWithoutRef;
+type ToastProps = React.ComponentPropsWithoutRef
-type ToastActionElement = React.ReactElement;
+type ToastActionElement = React.ReactElement
export {
type ToastProps,
@@ -125,4 +124,5 @@ export {
ToastDescription,
ToastClose,
ToastAction,
-};
+}
+
diff --git a/src/core/hooks/useCookie.ts b/src/core/hooks/useCookie.ts
new file mode 100644
index 00000000..df3d9d82
--- /dev/null
+++ b/src/core/hooks/useCookie.ts
@@ -0,0 +1,52 @@
+import Cookies, { type CookieAttributes } from "js-cookie";
+import { useCallback, useState } from "react";
+
+interface CookieHookResult {
+ value: T | undefined;
+ setCookie: (value: T, options?: CookieAttributes) => void;
+ removeCookie: () => void;
+}
+
+function useCookie(
+ cookieName: string,
+ initialValue?: T,
+): CookieHookResult {
+ const [cookieValue, setCookieValue] = useState(() => {
+ try {
+ const cookie = Cookies.get(cookieName);
+ return cookie ? (JSON.parse(cookie) as T) : initialValue;
+ } catch (error) {
+ console.error(`Error parsing cookie ${cookieName}:`, error);
+ return initialValue;
+ }
+ });
+
+ const setCookie = useCallback(
+ (value: T, options?: CookieAttributes) => {
+ try {
+ Cookies.set(cookieName, JSON.stringify(value), options);
+ setCookieValue(value);
+ } catch (error) {
+ console.error(`Error setting cookie ${cookieName}:`, error);
+ }
+ },
+ [cookieName],
+ );
+
+ const removeCookie = useCallback(() => {
+ try {
+ Cookies.remove(cookieName);
+ setCookieValue(undefined);
+ } catch (error) {
+ console.error(`Error removing cookie ${cookieName}:`, error);
+ }
+ }, [cookieName]);
+
+ return {
+ value: cookieValue,
+ setCookie,
+ removeCookie,
+ };
+}
+
+export default useCookie;
diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx
new file mode 100644
index 00000000..ee65d161
--- /dev/null
+++ b/src/core/hooks/useKeyBackupReminder.tsx
@@ -0,0 +1,120 @@
+import { Button } from "@app/components/UI/Button";
+import type { CookieAttributes } from "js-cookie";
+import { useCallback, useEffect, useRef } from "react";
+import useCookie from "./useCookie";
+import { useToast } from "./useToast";
+
+interface UseBackupReminderOptions {
+ reminderInDays?: number;
+ message: string;
+ onAccept?: () => void | Promise;
+ enabled: boolean;
+ cookieOptions?: CookieAttributes;
+}
+
+interface ReminderState {
+ suppressed: boolean;
+ lastShown: string;
+}
+
+const TOAST_APPEAR_DELAY = 10_000 // 10 seconds;
+const TOAST_DURATION = 30_000 // 30 seconds;:
+
+// remind user in 1 year to backup keys again, if they accept the reminder;
+const ON_ACCEPT_REMINDER_DAYS = 365
+
+function isReminderExpired(lastShown: string): boolean {
+ const lastShownDate = new Date(lastShown);
+ const now = new Date();
+ const daysSinceLastShown =
+ (now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24);
+ return daysSinceLastShown >= 7;
+}
+
+export function useBackupReminder({
+ reminderInDays = 7,
+ enabled,
+ message,
+ onAccept = () => { },
+ cookieOptions,
+}: UseBackupReminderOptions) {
+ const { toast } = useToast();
+ const toastShownRef = useRef(false);
+ const { value: reminderCookie, setCookie } =
+ useCookie("key_backup_reminder");
+
+ const suppressReminder = useCallback(
+ (days: number) => {
+ const expiryDate = new Date();
+ expiryDate.setDate(expiryDate.getDate() + days);
+
+ setCookie(
+ {
+ suppressed: true,
+ lastShown: new Date().toISOString(),
+ },
+ { ...cookieOptions, expires: expiryDate },
+ );
+ },
+ [setCookie, cookieOptions],
+ );
+
+ useEffect(() => {
+ if (!enabled || toastShownRef.current) return;
+
+ const shouldShowReminder =
+ !reminderCookie?.suppressed ||
+ isReminderExpired(reminderCookie.lastShown);
+ if (!shouldShowReminder) return;
+
+ toastShownRef.current = true;
+
+ const { dismiss } = toast(
+ {
+ title: "Backup Reminder",
+ duration: TOAST_DURATION,
+ delay: TOAST_APPEAR_DELAY,
+ description: message,
+ action: (
+
+
+
+
+ ),
+ },
+ );
+
+ return () => {
+ if (!toastShownRef.current) {
+ dismiss();
+ }
+ };
+ }, [
+ enabled,
+ message,
+ onAccept,
+ reminderInDays,
+ suppressReminder,
+ toast,
+ reminderCookie,
+ ]);
+}
diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts
index f64cf45a..c913d223 100644
--- a/src/core/hooks/useToast.ts
+++ b/src/core/hooks/useToast.ts
@@ -10,6 +10,7 @@ type ToasterToast = ToastProps & {
title?: ReactNode;
description?: ReactNode;
action?: ToastActionElement;
+ delay?: number;
};
const actionTypes = {
@@ -137,7 +138,7 @@ function dispatch(action: Action) {
type Toast = Omit;
-function toast({ ...props }: Toast) {
+function toast({ delay = 0, ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
@@ -147,17 +148,19 @@ function toast({ ...props }: Toast) {
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
- dispatch({
- type: "ADD_TOAST",
- toast: {
- ...props,
- id,
- open: true,
- onOpenChange: (open) => {
- if (!open) dismiss();
+ setTimeout(() => {
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ },
},
- },
- });
+ });
+ }, delay);
return {
id: id,
@@ -190,4 +193,4 @@ function useToast() {
};
}
-export { toast, useToast };
+export { toast, useToast };
\ No newline at end of file
diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts
index a716a85b..bd407611 100644
--- a/src/core/stores/deviceStore.ts
+++ b/src/core/stores/deviceStore.ts
@@ -25,7 +25,8 @@ export type DialogVariant =
| "shutdown"
| "reboot"
| "deviceName"
- | "nodeRemoval";
+ | "nodeRemoval"
+ | "pkiBackup";
export interface Device {
id: number;
@@ -60,6 +61,7 @@ export interface Device {
reboot: boolean;
deviceName: boolean;
nodeRemoval: boolean;
+ pkiBackup: boolean;
};
setStatus: (status: Types.DeviceStatusEnum) => void;
@@ -142,6 +144,7 @@ export const useDeviceStore = create((set, get) => ({
reboot: false,
deviceName: false,
nodeRemoval: false,
+ pkiBackup: false,
},
pendingSettingsChanges: false,
messageDraft: "",