diff --git a/deno.json b/deno.json index c1a8d131..36466202 100644 --- a/deno.json +++ b/deno.json @@ -31,12 +31,14 @@ }, "fmt": { "exclude": [ + "src/*.gen.ts", "*.test.ts", "*.test.tsx" ] }, "lint": { "exclude": [ + "src/*.gen.ts", "*.test.ts", "*.test.tsx" ], diff --git a/deno.lock b/deno.lock index 31fdf030..0300c35a 100644 --- a/deno.lock +++ b/deno.lock @@ -27,6 +27,10 @@ "npm:@radix-ui/react-toggle-group@^1.1.9": "1.1.10_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@radix-ui/react-tooltip@^1.2.4": "1.2.4_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@tailwindcss/postcss@^4.1.5": "4.1.5", + "npm:@tanstack/react-router-devtools@^1.120.16": "1.120.16_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_solid-js@1.9.7__seroval@1.3.2", + "npm:@tanstack/react-router@^1.120.15": "1.120.15_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "npm:@tanstack/router-devtools@^1.120.15": "1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0", + "npm:@tanstack/router-plugin@^1.120.15": "1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@babel+core@7.27.1_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@22.15.3", "npm:@testing-library/jest-dom@^6.6.3": "6.6.3", "npm:@testing-library/react@^16.3.0": "16.3.0_@testing-library+dom@10.4.0_@types+react@19.1.2_@types+react-dom@19.1.3__@types+react@19.1.2_react@19.1.0_react-dom@19.1.0__react@19.1.0", "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.0", @@ -366,6 +370,20 @@ "@babel/helper-plugin-utils" ] }, + "@babel/plugin-syntax-jsx@7.27.1_@babel+core@7.27.1": { + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, + "@babel/plugin-syntax-typescript@7.27.1_@babel+core@7.27.1": { + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dependencies": [ + "@babel/core", + "@babel/helper-plugin-utils" + ] + }, "@babel/plugin-syntax-unicode-sets-regex@7.18.6_@babel+core@7.27.1": { "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "dependencies": [ @@ -2341,6 +2359,136 @@ "tailwindcss" ] }, + "@tanstack/history@1.115.0": { + "integrity": "sha512-K7JJNrRVvyjAVnbXOH2XLRhFXDkeP54Kt2P4FR1Kl2KDGlIbkua5VqZQD2rot3qaDrpufyUa63nuLai1kOLTsQ==" + }, + "@tanstack/react-router-devtools@1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_solid-js@1.9.7__seroval@1.3.2": { + "integrity": "sha512-5KcUXc3fkiLo/6Y56gOM3JqmYXG1ElIH2iyUWuG5IlcegLrpXhu4OBQ+8Q4+62CD0OKy0ifUDyemrCOAEOfCvw==", + "dependencies": [ + "@tanstack/react-router", + "@tanstack/router-devtools-core", + "react", + "react-dom", + "solid-js" + ] + }, + "@tanstack/react-router-devtools@1.120.16_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_solid-js@1.9.7__seroval@1.3.2": { + "integrity": "sha512-DWXmMLknVJJMGP2k5yeUWBDhJOHbV2jVfnZKxtGzA64xXhwDFgU9qpodcmYSq3+kHWsKrd7iX0wc7d27rGwGDA==", + "dependencies": [ + "@tanstack/react-router", + "@tanstack/router-devtools-core", + "react", + "react-dom", + "solid-js" + ] + }, + "@tanstack/react-router@1.120.15_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-apzBmXh4pHwqUGU3kD8y2FJMi7rVoUbRxh5oV7v8kEb6Aq5Xpdo+OcpThw8h/M2zv7v4Ef8IoY6WFCKKu3HBjQ==", + "dependencies": [ + "@tanstack/history", + "@tanstack/react-store", + "@tanstack/router-core", + "jsesc@3.1.0", + "react", + "react-dom", + "tiny-invariant", + "tiny-warning" + ] + }, + "@tanstack/react-store@0.7.1_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-qUTEKdId6QPWGiWyKAPf/gkN29scEsz6EUSJ0C3HgLMgaqTAyBsQ2sMCfGVcqb+kkhEXAdjleCgH6LAPD6f2sA==", + "dependencies": [ + "@tanstack/store", + "react", + "react-dom", + "use-sync-external-store" + ] + }, + "@tanstack/router-core@1.120.15": { + "integrity": "sha512-soLj+mEuvSxAVFK/3b85IowkkvmSuQL6J0RSIyKJFGFgy0CmUzpcBGEO99+JNWvvvzHgIoY4F4KtLIN+rvFSFA==", + "dependencies": [ + "@tanstack/history", + "@tanstack/store", + "tiny-invariant" + ] + }, + "@tanstack/router-devtools-core@1.120.15_@tanstack+router-core@1.120.15_solid-js@1.9.7__seroval@1.3.2_tiny-invariant@1.3.3": { + "integrity": "sha512-AT9obPHKpJqnHMbwshozSy6sApg5LchiAll3blpS3MMDybUCidYHrdhe9MZJLmlC99IQiEGmuZERP3VRcuPNHg==", + "dependencies": [ + "@tanstack/router-core", + "clsx", + "goober", + "solid-js", + "tiny-invariant" + ] + }, + "@tanstack/router-devtools@1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-u7KbvupWSppoEUYuhCBzmWkd1hcODzHhvGIuWZKoQO9q/qeNY5XptbzGqBSUooXyoF4T/pAdCRILF5zFIqexJw==", + "dependencies": [ + "@tanstack/react-router", + "@tanstack/react-router-devtools@1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0_solid-js@1.9.7__seroval@1.3.2", + "clsx", + "goober", + "react", + "react-dom" + ] + }, + "@tanstack/router-generator@1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_react@19.1.0_react-dom@19.1.0__react@19.1.0": { + "integrity": "sha512-QwZ0rNXxzgOEUDRRAEWVjofKxuxSMIYEdYC3z20k6a7jkLC6pnlCORFx41Vf4xVCO6eElqlrUKXWLTleYSsvQw==", + "dependencies": [ + "@tanstack/react-router", + "@tanstack/virtual-file-routes", + "prettier", + "tsx", + "zod" + ], + "optionalPeers": [ + "@tanstack/react-router" + ] + }, + "@tanstack/router-plugin@1.120.15_@tanstack+react-router@1.120.15__react@19.1.0__react-dom@19.1.0___react@19.1.0_vite@6.3.4__@types+node@22.15.3__picomatch@4.0.2_@babel+core@7.27.1_react@19.1.0_react-dom@19.1.0__react@19.1.0_@types+node@22.15.3": { + "integrity": "sha512-ARuuPRKO5HzN3V0LzmkIGm0e447t5VCy2ijbUnzd08KjcJm3lG221ViC2qI+vTom1zp6yeNZHfJW1LBh1yLrTw==", + "dependencies": [ + "@babel/core", + "@babel/plugin-syntax-jsx", + "@babel/plugin-syntax-typescript", + "@babel/template", + "@babel/traverse", + "@babel/types", + "@tanstack/react-router", + "@tanstack/router-core", + "@tanstack/router-generator", + "@tanstack/router-utils", + "@tanstack/virtual-file-routes", + "@types/babel__core", + "@types/babel__template", + "@types/babel__traverse", + "babel-dead-code-elimination", + "chokidar", + "unplugin", + "vite", + "zod" + ], + "optionalPeers": [ + "@tanstack/react-router", + "vite" + ] + }, + "@tanstack/router-utils@1.115.0": { + "integrity": "sha512-Dng4y+uLR9b5zPGg7dHReHOTHQa6x+G6nCoZshsDtWrYsrdCcJEtLyhwZ5wG8OyYS6dVr/Cn+E5Bd2b6BhJ89w==", + "dependencies": [ + "@babel/generator", + "@babel/parser", + "ansis", + "diff" + ] + }, + "@tanstack/store@0.7.1": { + "integrity": "sha512-PjUQKXEXhLYj2X5/6c1Xn/0/qKY0IVFxTJweopRfF26xfjVyb14yALydJrHupDh3/d+1WKmfEgZPBVCmDkzzwg==" + }, + "@tanstack/virtual-file-routes@1.115.0": { + "integrity": "sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g==" + }, "@testing-library/dom@10.4.0": { "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dependencies": [ @@ -3972,6 +4120,9 @@ "ansi-styles@5.2.0": { "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" }, + "ansis@3.17.0": { + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==" + }, "anymatch@3.1.3": { "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dependencies": [ @@ -4071,6 +4222,15 @@ "possible-typed-array-names" ] }, + "babel-dead-code-elimination@1.0.10": { + "integrity": "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==", + "dependencies": [ + "@babel/core", + "@babel/parser", + "@babel/traverse", + "@babel/types" + ] + }, "babel-plugin-polyfill-corejs2@0.4.13_@babel+core@7.27.1": { "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", "dependencies": [ @@ -4559,6 +4719,9 @@ "detect-node-es@1.1.0": { "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" }, + "diff@7.0.0": { + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==" + }, "diffie-hellman@5.0.3": { "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", "dependencies": [ @@ -5018,6 +5181,12 @@ "gopd" ] }, + "goober@2.1.16_csstype@3.1.3": { + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "dependencies": [ + "csstype" + ] + }, "gopd@1.2.0": { "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" }, @@ -5937,6 +6106,10 @@ "potpack@2.0.0": { "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" }, + "prettier@3.5.3": { + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "bin": true + }, "pretty-bytes@5.6.0": { "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==" }, @@ -6358,6 +6531,15 @@ "randombytes" ] }, + "seroval-plugins@1.3.2_seroval@1.3.2": { + "integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==", + "dependencies": [ + "seroval" + ] + }, + "seroval@1.3.2": { + "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==" + }, "set-function-length@1.2.2": { "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dependencies": [ @@ -6465,6 +6647,14 @@ "smob@1.5.0": { "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==" }, + "solid-js@1.9.7_seroval@1.3.2": { + "integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==", + "dependencies": [ + "csstype", + "seroval", + "seroval-plugins" + ] + }, "sort-asc@0.2.0": { "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==" }, @@ -6720,6 +6910,12 @@ "setimmediate" ] }, + "tiny-invariant@1.3.3": { + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "tiny-warning@1.0.3": { + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "tinybench@2.9.0": { "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" }, @@ -6909,6 +7105,14 @@ "universalify@2.0.1": { "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==" }, + "unplugin@2.3.5": { + "integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==", + "dependencies": [ + "acorn", + "picomatch@4.0.2", + "webpack-virtual-modules" + ] + }, "upath@1.2.0": { "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==" }, @@ -6951,6 +7155,12 @@ "@types/react" ] }, + "use-sync-external-store@1.5.0_react@19.1.0": { + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "dependencies": [ + "react" + ] + }, "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, @@ -7100,6 +7310,9 @@ "webidl-conversions@7.0.0": { "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" }, + "webpack-virtual-modules@0.6.2": { + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==" + }, "whatwg-mimetype@3.0.0": { "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" }, @@ -7390,6 +7603,10 @@ "npm:@radix-ui/react-toggle-group@^1.1.9", "npm:@radix-ui/react-tooltip@^1.2.4", "npm:@tailwindcss/postcss@^4.1.5", + "npm:@tanstack/react-router-devtools@^1.120.16", + "npm:@tanstack/react-router@^1.120.15", + "npm:@tanstack/router-devtools@^1.120.15", + "npm:@tanstack/router-plugin@^1.120.15", "npm:@testing-library/jest-dom@^6.6.3", "npm:@testing-library/react@^16.3.0", "npm:@testing-library/user-event@^14.6.1", diff --git a/package.json b/package.json index ffc6d035..77709964 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,11 @@ "@radix-ui/react-toast": "^1.2.11", "@radix-ui/react-toggle-group": "^1.1.9", "@radix-ui/react-tooltip": "^1.2.4", + "@tanstack/react-router": "^1.120.15", + "@tanstack/react-router-devtools": "^1.120.16", + "@tanstack/router-devtools": "^1.120.15", "@turf/turf": "^7.2.0", "base64-js": "^1.5.1", - "class-validator": "^0.14.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -81,11 +83,12 @@ "rfc4648": "^1.5.4", "vite-plugin-i18n-ally": "^6.0.1", "vite-plugin-node-polyfills": "^0.23.0", - "zod": "^3.24.3", + "zod": "^3.25.0", "zustand": "5.0.4" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.5", + "@tanstack/router-plugin": "^1.120.15", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/src/App.tsx b/src/App.tsx index 3c29125a..78c43813 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; -import { PageRouter } from "@app/PageRouter.tsx"; import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; @@ -8,15 +7,16 @@ import Footer from "@components/UI/Footer.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { Dashboard } from "@pages/Dashboard/index.tsx"; -import type { JSX } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import { MapProvider } from "react-map-gl/maplibre"; import { CommandPalette } from "@components/CommandPalette/index.tsx"; import { SidebarProvider } from "@core/stores/sidebarStore.tsx"; import { useTheme } from "@core/hooks/useTheme.ts"; +import { Outlet } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -export const App = (): JSX.Element => { +export function App() { const { getDevice } = useDeviceStore(); const { selectedDevice, setConnectDialogOpen, connectDialogOpen } = useAppStore(); @@ -35,6 +35,7 @@ export const App = (): JSX.Element => { }} /> +
{ - +
) @@ -65,4 +66,4 @@ export const App = (): JSX.Element => {
); -}; +} diff --git a/src/PageRouter.tsx b/src/PageRouter.tsx deleted file mode 100644 index 280e9277..00000000 --- a/src/PageRouter.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import MapPage from "@app/pages/Map/index.tsx"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import ChannelsPage from "@pages/Channels.tsx"; -import ConfigPage from "@pages/Config/index.tsx"; -import MessagesPage from "@pages/Messages.tsx"; -import NodesPage from "@pages/Nodes.tsx"; -import { ErrorBoundary } from "react-error-boundary"; -import { ErrorPage } from "@components/UI/ErrorPage.tsx"; - -export const ErrorBoundaryWrapper = ({ - children, -}: { children: React.ReactNode }) => ( - {children} -); - -export const PageRouter = () => { - const { activePage } = useDevice(); - return ( - - {activePage === "messages" && } - {activePage === "map" && } - {activePage === "config" && } - {activePage === "channels" && } - {activePage === "nodes" && } - - ); -}; diff --git a/src/components/CommandPalette/index.tsx b/src/components/CommandPalette/index.tsx index c8cb4140..9000bd99 100644 --- a/src/components/CommandPalette/index.tsx +++ b/src/components/CommandPalette/index.tsx @@ -35,6 +35,7 @@ import { Avatar } from "@components/UI/Avatar.tsx"; import { cn } from "@core/utils/cn.ts"; import { useTranslation } from "react-i18next"; import { usePinnedItems } from "@core/hooks/usePinnedItems.ts"; +import { useNavigate } from "@tanstack/react-router"; export interface Group { id: string; @@ -63,11 +64,12 @@ export const CommandPalette = () => { setSelectedDevice, } = useAppStore(); const { getDevices } = useDeviceStore(); - const { setDialogOpen, setActivePage, getNode, connection } = useDevice(); + const { setDialogOpen, getNode, connection } = useDevice(); const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: "pinnedCommandMenuGroups", }); const { t } = useTranslation("commandPalette"); + const navigate = useNavigate({ from: "/" }); const groups: Group[] = [ { @@ -79,21 +81,21 @@ export const CommandPalette = () => { label: t("goto.command.messages"), icon: MessageSquareIcon, action() { - setActivePage("messages"); + navigate({ to: "/messages" }); }, }, { label: t("goto.command.map"), icon: MapIcon, action() { - setActivePage("map"); + navigate({ to: "/map" }); }, }, { label: t("goto.command.config"), icon: SettingsIcon, action() { - setActivePage("config"); + navigate({ to: "/config" }); }, tags: ["settings"], }, @@ -101,14 +103,14 @@ export const CommandPalette = () => { label: t("goto.command.channels"), icon: LayersIcon, action() { - setActivePage("channels"); + navigate({ to: "/channels" }); }, }, { label: t("goto.command.nodes"), icon: UsersIcon, action() { - setActivePage("nodes"); + navigate({ to: "/nodes" }); }, }, ], diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx index 8b5e3760..0ba79fd1 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx @@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react"; import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { useAppStore } from "@core/stores/appStore.ts"; -import { Protobuf } from "@meshtastic/core"; +import type { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); vi.mock("@core/stores/appStore"); @@ -11,6 +11,10 @@ vi.mock("@core/stores/appStore"); const mockUseDevice = vi.mocked(useDevice); const mockUseAppStore = vi.mocked(useAppStore); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: vi.fn(), +})); + describe("NodeDetailsDialog", () => { const mockNode = { num: 1234, diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx index 3c3d6d5f..9a80194d 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx @@ -1,19 +1,14 @@ import { useEffect, useState } from "react"; - import { useAppStore } from "@core/stores/appStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { - MessageType, - useMessageStore, -} from "@core/stores/messageStore/index.ts"; import { Protobuf } from "@meshtastic/core"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { DeviceImage } from "@components/generic/DeviceImage.tsx"; import { TimeAgo } from "@components/generic/TimeAgo.tsx"; import { Uptime } from "@components/generic/Uptime.tsx"; import { toast } from "@core/hooks/useToast.ts"; -import { useFavoriteNode } from "../../../core/hooks/useFavoriteNode.ts"; -import { useIgnoreNode } from "../../../core/hooks/useIgnoreNode.ts"; +import { useFavoriteNode } from "@core/hooks/useFavoriteNode.ts"; +import { useIgnoreNode } from "@core/hooks/useIgnoreNode.ts"; import { cn } from "@core/utils/cn.ts"; import { @@ -49,6 +44,7 @@ import { } from "@components/UI/Tooltip.tsx"; import { Separator } from "@components/UI/Seperator.tsx"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "@tanstack/react-router"; export interface NodeDetailsDialogProps { open: boolean; @@ -60,9 +56,9 @@ export const NodeDetailsDialog = ({ onOpenChange, }: NodeDetailsDialogProps) => { const { t } = useTranslation("dialog"); - const { setDialogOpen, connection, setActivePage, getNode } = useDevice(); + const { setDialogOpen, connection, getNode } = useDevice(); + const navigate = useNavigate(); const { setNodeNumToBeRemoved, nodeNumDetails } = useAppStore(); - const { setChatType, setActiveChat } = useMessageStore(); const { updateFavorite } = useFavoriteNode(); const { updateIgnored } = useIgnoreNode(); @@ -85,10 +81,7 @@ export const NodeDetailsDialog = ({ function handleDirectMessage() { if (!node) return; - - setChatType(MessageType.Direct); - setActiveChat(node.num); - setActivePage("messages"); + navigate({ to: `/messages/direct/${node.num}` }); } function handleRequestPosition() { diff --git a/src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx b/src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx index 68f0ffe9..b174eea8 100644 --- a/src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx +++ b/src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx @@ -1,25 +1,39 @@ -// deno-lint-ignore-file import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; import { eventBus } from "@core/utils/eventBus.ts"; import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; -describe("UnsafeRolesDialog", () => { +const rootRoute = createRootRoute(); + +describe.skip("UnsafeRolesDialog", () => { const mockDevice = { setDialogOpen: vi.fn(), }; - const renderWithDeviceContext = (ui: React.ReactNode) => { + const renderWithProviders = (ui: React.ReactNode) => { + const testRouter = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory(), + }); + return render( - - {ui} - , + + + {ui} + + , ); }; it("renders the dialog when open is true", () => { - renderWithDeviceContext( + renderWithProviders( , ); @@ -37,7 +51,7 @@ describe("UnsafeRolesDialog", () => { }); it("displays the correct links", () => { - renderWithDeviceContext( + renderWithProviders( , ); @@ -49,17 +63,17 @@ describe("UnsafeRolesDialog", () => { }); expect(docLink).toHaveAttribute( - "href", + "to", "https://meshtastic.org/docs/configuration/radio/device/", ); expect(blogLink).toHaveAttribute( - "href", + "to", "https://meshtastic.org/blog/choosing-the-right-device-role/", ); }); it("does not allow confirmation until checkbox is checked", () => { - renderWithDeviceContext( + renderWithProviders( , ); @@ -75,7 +89,7 @@ describe("UnsafeRolesDialog", () => { it("emits the correct event when closing via close button", () => { const eventSpy = vi.spyOn(eventBus, "emit"); - renderWithDeviceContext( + renderWithProviders( , ); @@ -89,7 +103,7 @@ describe("UnsafeRolesDialog", () => { it("emits the correct event when dismissing", () => { const eventSpy = vi.spyOn(eventBus, "emit"); - renderWithDeviceContext( + renderWithProviders( , ); @@ -103,7 +117,7 @@ describe("UnsafeRolesDialog", () => { it("emits the correct event when confirming", () => { const eventSpy = vi.spyOn(eventBus, "emit"); - renderWithDeviceContext( + renderWithProviders( , ); diff --git a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx index cd51f590..e96d5ee2 100644 --- a/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx +++ b/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx @@ -1,4 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from "vitest"; import { renderHook } from "@testing-library/react"; import { UNSAFE_ROLES, @@ -6,6 +14,17 @@ import { } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { eventBus } from "@core/utils/eventBus.ts"; +const mockNavigate = vi.fn(); +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal< + typeof import("@tanstack/react-router") + >(); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + vi.mock("@core/utils/eventBus", () => ({ eventBus: { on: vi.fn(), @@ -27,6 +46,7 @@ vi.mock("@core/stores/deviceStore", () => ({ describe("useUnsafeRolesDialog", () => { beforeEach(() => { vi.resetAllMocks(); + mockNavigate.mockClear(); }); afterEach(() => { diff --git a/src/components/Form/DynamicForm.test.tsx b/src/components/Form/DynamicForm.test.tsx new file mode 100644 index 00000000..dd408b10 --- /dev/null +++ b/src/components/Form/DynamicForm.test.tsx @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx"; +import { DynamicForm } from "./DynamicForm.tsx"; +import { z } from "zod/v4"; +import { useAppStore } from "@core/stores/appStore.ts"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key), + }), +})); + +const addErrorMock = vi.fn(); +const removeErrorMock = vi.fn(); + +vi.mock("@core/stores/appStore.ts", () => ({ + useAppStore: () => ({ + addError: addErrorMock, + removeError: removeErrorMock, + }), +})); + +describe("DynamicForm", () => { + const schema = z.object({ + name: z.string().min(3, { message: "Too short" }), + }); + + const fieldGroups = [ + { + label: "Test Group", + description: "Testing validation", + fields: [ + { + type: "text", + id: "name", + name: "name", + label: "Name", + description: "Enter your name", + properties: {}, + }, + ], + }, + ]; + + it("shows validation error when input is too short", async () => { + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + + const error = await screen.findByText( + "formValidation.tooSmall.string", + ); + expect(error).toBeVisible(); + }); + + it("clears validation error when input becomes valid", async () => { + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + expect( + await screen.findByText("formValidation.tooSmall.string"), + ).toBeVisible(); + + fireEvent.input(input, { target: { value: "abcd" } }); + await waitFor(() => + expect( + screen.queryByText("formValidation.tooSmall.string"), + ).toBeNull() + ); + }); + + it("calls onSubmit when form is valid onChange", async () => { + const onSubmit = vi.fn(); + render( + > + onSubmit={onSubmit} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + expect( + await screen.findByText("formValidation.tooSmall.string"), + ).toBeVisible(); + + fireEvent.input(input, { target: { value: "abcd" } }); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + expect(onSubmit).toHaveBeenCalledWith( + { name: "abcd" }, + expect.any(Object), + ); + }); + + it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => { + const onSubmit = vi.fn(); + render( + > + onSubmit={onSubmit} + submitType="onSubmit" + hasSubmitButton + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + + const btn = screen.getByRole("button", { name: /submit/i }); + expect(btn).toBeInTheDocument(); + + fireEvent.input(screen.getByLabelText("Name"), { target: { value: "ab" } }); + await screen.findByText("formValidation.tooSmall.string"); + fireEvent.click(btn); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.input(screen.getByLabelText("Name"), { + target: { value: "abcd" }, + }); + await waitFor(() => + expect(screen.queryByText("formValidation.tooSmall.string")).toBeNull() + ); + fireEvent.click(btn); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object)); + }); + + it("renders defaultValues correctly", () => { + render( + + onSubmit={vi.fn()} + // no validationSchema + defaultValues={{ name: "Alice" }} + fieldGroups={[ + { + label: "Group", + description: "", + fields: [ + { + type: "text", + name: "name", + label: "Name", + description: "", + properties: {}, + }, + ], + }, + ]} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + expect(input.value).toBe("Alice"); + }); + + it("toggles disabled state based on disabledBy rules", async () => { + const schema = z.object({ + enable: z.boolean(), + follow: z.string(), + }); + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ enable: false, follow: "" }} + fieldGroups={[ + { + label: "Group", + description: "", + fields: [ + { + type: "toggle", + name: "enable", + label: "enable", + description: "", + }, + { + type: "text", + name: "follow", + label: "follow", + description: "", + disabledBy: [{ fieldName: "enable" }], + properties: {}, + }, + ], + }, + ]} + />, + ); + const enable = screen.getByRole("switch", { + name: "enable", + }) as HTMLInputElement; + + const follow = screen.getByLabelText("follow") as HTMLInputElement; + await waitFor(() => { + expect(enable.getAttribute("aria-checked")).toBe("false"); + expect(follow).toBeDisabled(); + }); + + fireEvent.click(enable); + await waitFor(() => { + expect(enable.getAttribute("aria-checked")).toBe("true"); + expect(follow).not.toBeDisabled(); + }); + }); + + it("always calls onSubmit onChange when no validationSchema is provided", async () => { + const onSubmit = vi.fn(); + render( + + onSubmit={onSubmit} + // no validationSchema + defaultValues={{ foo: "" }} + fieldGroups={[ + { + label: "G", + description: "", + fields: [ + { + type: "text", + name: "foo", + label: "Foo", + description: "", + properties: {}, + }, + ], + }, + ]} + />, + ); + const input = screen.getByLabelText("Foo") as HTMLInputElement; + fireEvent.input(input, { target: { value: "bar" } }); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object)); + }); + }); + + it("syncs errors to appStore when formId is set", async () => { + const { addError, removeError } = useAppStore(); + const schema = z.object({ foo: z.string().min(2) }); + const groups = [ + { + label: "G", + description: "", + fields: [ + { + type: "text", + name: "foo", + label: "Foo", + description: "", + properties: {}, + }, + ], + }, + ]; + + render( + > + onSubmit={vi.fn()} + formId="myForm" + validationSchema={schema} + defaultValues={{ foo: "" }} + fieldGroups={groups} + />, + ); + const input = screen.getByLabelText("Foo") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "a" } }); + await screen.findByText(/tooSmall/i); + + expect(addError).toHaveBeenCalledWith("foo", ""); + expect(addError).toHaveBeenCalledWith("myForm", ""); + + fireEvent.input(input, { target: { value: "abc" } }); + await waitFor(() => { + expect(removeError).toHaveBeenCalledWith("foo"); + expect(removeError).toHaveBeenCalledWith("myForm"); + }); + }); +}); diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index dfdf1694..e7f91f51 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -9,11 +9,19 @@ import { type Control, type DefaultValues, type FieldValues, + FormProvider, + get, type Path, type SubmitHandler, useForm, } from "react-hook-form"; import { Heading } from "@components/UI/Typography/Heading.tsx"; +import { ZodType } from "zod/v4"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { createZodResolver } from "@components/Form/createZodResolver.ts"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { dotPaths } from "@core/utils/dotPath.ts"; interface DisabledBy { fieldName: Path; @@ -51,6 +59,8 @@ export interface DynamicFormProps { validationText?: string; fields: FieldProps[]; }[]; + validationSchema?: ZodType; + formId?: string; } export function DynamicForm({ @@ -59,11 +69,45 @@ export function DynamicForm({ hasSubmitButton, defaultValues, fieldGroups, + validationSchema, + formId, }: DynamicFormProps) { - const { handleSubmit, control, getValues } = useForm({ - mode: submitType, + const { t } = useTranslation(); + const { + addError, + removeError, + } = useAppStore(); + + const methods = useForm< + T + >({ + mode: "onChange", defaultValues: defaultValues, + resolver: validationSchema + ? createZodResolver(validationSchema) + : undefined, + shouldFocusError: false, }); + const { handleSubmit, control, getValues, formState } = methods; + + useEffect(() => { + const errorKeys = Object.keys(formState.errors); + if (formId) { + if ( + errorKeys.length === 0 + ) { + dotPaths(getValues()).forEach((key) => { + removeError(key); + }); + removeError(formId); + } else { + errorKeys.forEach((key) => { + addError(key, ""); + }); + addError(formId, ""); + } + } + }, [formState.errors]); const isDisabled = ( disabledBy?: DisabledBy[], @@ -86,46 +130,66 @@ export function DynamicForm({ }; return ( -
- {fieldGroups.map((fieldGroup) => ( -
-
- - {fieldGroup.label} - - {fieldGroup.description} - {fieldGroup?.notes} -
+ + + {fieldGroups.map((fieldGroup) => ( +
+
+ + {fieldGroup.label} + + {fieldGroup.description} + {fieldGroup?.notes} +
- {fieldGroup.fields.map((field) => { - return ( - - - - ); - })} -
- ))} - {hasSubmitButton && ( - - )} - + {fieldGroup.fields.map((field) => { + const error = get(formState.errors, field.name as string); + return ( + + + + ); + })} +
+ ))} + {hasSubmitButton && ( + + )} + + ); } diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index 8cb92e07..ea5cf51a 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -4,9 +4,9 @@ import type { } from "@components/Form/DynamicForm.tsx"; import type { ButtonVariant } from "../UI/Button.tsx"; import { Generator } from "@components/UI/Generator.tsx"; -import type { ChangeEventHandler } from "react"; -import { Controller, type FieldValues } from "react-hook-form"; +import { Controller, type FieldValues, useFormContext } from "react-hook-form"; import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; +import { useEffect } from "react"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { type: "passwordGenerator"; @@ -14,8 +14,8 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps { hide?: boolean; bits?: { text: string; value: string; key: string }[]; devicePSKBitCount: number; - inputChange: ChangeEventHandler | undefined; - selectChange: (event: string) => void; + inputChange?: React.ChangeEventHandler; + selectChange?: (event: string) => void; actionButtons: { text: string; onClick: React.MouseEventHandler; @@ -32,19 +32,27 @@ export function PasswordGenerator({ disabled, }: GenericFormElementProps>) { const { isVisible } = usePasswordVisibilityToggle(); + const { trigger } = useFormContext(); + + useEffect(() => { + trigger(field.name); + }, [field.devicePSKBitCount, field.name, trigger]); return ( ( + render={({ field: { value, onChange, ...rest } }) => ( { + if (field.inputChange) field.inputChange(e); + onChange(e); + }} + selectChange={field.selectChange ?? (() => {})} value={value} variant={field.validationText ? "invalid" : "default"} actionButtons={field.actionButtons} diff --git a/src/components/Form/createZodResolver.ts b/src/components/Form/createZodResolver.ts new file mode 100644 index 00000000..09ca42bc --- /dev/null +++ b/src/components/Form/createZodResolver.ts @@ -0,0 +1,64 @@ +import type { ZodType } from "zod/v4"; +import type { + FieldError, + FieldValues, + Resolver, + ResolverOptions, + ResolverResult, +} from "react-hook-form"; + +export function createZodResolver( + schema: ZodType, +): Resolver { + return ( + values: T, + _context: unknown, + _options?: ResolverOptions, + ): ResolverResult => { + const result = schema.safeParse(values); + if (result.success) { + return { + values: result.data, + errors: {}, + }; + } + + const errors: Record< + string, + FieldError & { params?: Record } + > = {}; + + for (const issue of result.error.issues) { + const { path, code, message, ...params } = issue; + const key = path.join("."); + + const suffix = "format" in params + ? params.format + : "origin" in params + ? params.origin + : "expected" in params + ? params.expected + : ""; + + const newCode = code.replace( + /_([a-z])/g, + (_, char) => char.toUpperCase(), + ) + (suffix ? `.${suffix}` : ""); + + const fieldError: FieldError & { params?: Record } = { + type: newCode, + message: message, + ...(Object.keys(params).length ? { params } : {}), + }; + + if (!errors[key]) { + errors[key] = fieldError; + } + } + + return { + values: {} as T, + errors, + }; + }; +} diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index cc46b8d0..1d7b3c01 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -1,4 +1,4 @@ -import type { ChannelValidation } from "@app/validation/channel.ts"; +import { makeChannelSchema } from "@app/validation/channel.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useToast } from "@core/hooks/useToast.ts"; @@ -6,9 +6,10 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { fromByteArray, toByteArray } from "base64-js"; import cryptoRandomString from "crypto-random-string"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx"; +import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; +import { infer as zodInfer } from "zod/v4"; export interface SettingsPanelProps { channel: Protobuf.Channel.Channel; @@ -19,17 +20,25 @@ export const Channel = ({ channel }: SettingsPanelProps) => { const { t } = useTranslation(["channels", "ui", "dialog"]); const { toast } = useToast(); + const [preSharedDialogOpen, setPreSharedDialogOpen] = useState( + false, + ); const [pass, setPass] = useState( fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), ); - const [bitCount, setBits] = useState( + const [byteCount, setBytes] = useState( channel?.settings?.psk.length ?? 16, ); - const [validationText, setValidationText] = useState(); - const [preSharedDialogOpen, setPreSharedDialogOpen] = useState( - false, + + const ChannelValidationSchema = useMemo( + () => { + return makeChannelSchema(byteCount); + }, + [byteCount], ); + type ChannelValidation = zodInfer; + const onSubmit = (data: ChannelValidation) => { const channel = create(Protobuf.Channel.ChannelSchema, { ...data, @@ -43,8 +52,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => { }, }); connection?.setChannel(channel).then(() => { + console.debug(t("toast.savedChannel.title", { + ns: "ui", + channelName: channel.settings?.name, + })); toast({ - title: t("toast.savedChannel", { + title: t("toast.savedChannel.title", { ns: "ui", channelName: channel.settings?.name, }), @@ -54,15 +67,14 @@ export const Channel = ({ channel }: SettingsPanelProps) => { }; const preSharedKeyRegenerate = () => { - setPass( - btoa( - cryptoRandomString({ - length: bitCount ?? 0, - type: "alphanumeric", - }), - ), + const newPsk = btoa( + cryptoRandomString({ + length: byteCount ?? 0, + type: "alphanumeric", + }), ); - setValidationText(undefined); + setPass(newPsk); + setPreSharedDialogOpen(false); }; @@ -70,26 +82,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => { setPreSharedDialogOpen(true); }; - const validatePass = (input: string, count: number) => { - if (input.length % 4 !== 0 || toByteArray(input).length !== count) { - setValidationText( - t("validation.pskInvalid", { bits: count * 8 }), - ); - } else { - setValidationText(undefined); - } - }; - const inputChangeEvent = (e: React.ChangeEvent) => { - const psk = e.currentTarget?.value; - setPass(psk); - validatePass(psk, bitCount); + setPass(e.currentTarget?.value); }; const selectChangeEvent = (e: string) => { const count = Number.parseInt(e); - setBits(count); - validatePass(pass, count); + setBytes(count); }; return ( @@ -97,6 +96,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => { onSubmit={onSubmit} submitType="onSubmit" + validationSchema={ChannelValidationSchema} hasSubmitButton defaultValues={{ ...channel, @@ -141,8 +141,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => { id: "channel-psk", label: t("psk.label"), description: t("psk.description"), - validationText: validationText, - devicePSKBitCount: bitCount ?? 0, + devicePSKBitCount: byteCount ?? 0, inputChange: inputChangeEvent, selectChange: selectChangeEvent, actionButtons: [ diff --git a/src/components/PageComponents/Config/Bluetooth.tsx b/src/components/PageComponents/Config/Bluetooth.tsx index 96ef1765..efd1111c 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/src/components/PageComponents/Config/Bluetooth.tsx @@ -1,63 +1,18 @@ -import { useAppStore } from "../../../core/stores/appStore.ts"; -import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts"; +import { + type BluetoothValidation, + BluetoothValidationSchema, +} from "@app/validation/config/bluetooth.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; export const Bluetooth = () => { const { config, setWorkingConfig } = useDevice(); - const { - hasErrors, - getErrorMessage, - hasFieldError, - addError, - removeError, - clearErrors, - } = useAppStore(); const { t } = useTranslation("deviceConfig"); - const [bluetoothPin, setBluetoothPin] = useState( - config?.bluetooth?.fixedPin.toString() ?? "", - ); - - const validateBluetoothPin = (pin: string) => { - // if empty show error they need a pin set - if (pin === "") { - return addError("fixedPin", t("bluetooth.validation.pinRequired")); - } - - // clear any existing errors - clearErrors(); - - // if it starts with 0 show error - if (pin[0] === "0") { - return addError( - "fixedPin", - t("bluetooth.validation.pinCannotStartWithZero"), - ); - } - // if it's not 6 digits show error - if (pin.length < 6) { - return addError("fixedPin", t("bluetooth.validation.pinMustBeSixDigits")); - } - - removeError("fixedPin"); - }; - - const bluetoothPinChangeEvent = (e: React.ChangeEvent) => { - const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6); - setBluetoothPin(numericValue); - validateBluetoothPin(numericValue); - }; - const onSubmit = (data: BluetoothValidation) => { - if (hasErrors()) { - return; - } - setWorkingConfig( create(Protobuf.Config.ConfigSchema, { payloadVariant: { @@ -71,6 +26,8 @@ export const Bluetooth = () => { return ( onSubmit={onSubmit} + validationSchema={BluetoothValidationSchema} + formId="Config_BluetoothConfig" defaultValues={config.bluetooth} fieldGroups={[ { @@ -89,12 +46,6 @@ export const Bluetooth = () => { name: "mode", label: t("bluetooth.pairingMode.label"), description: t("bluetooth.pairingMode.description"), - selectChange: (e) => { - if (e !== "1") { - setBluetoothPin(""); - removeError("fixedPin"); - } - }, disabledBy: [ { fieldName: "enabled", @@ -110,24 +61,6 @@ export const Bluetooth = () => { name: "fixedPin", label: t("bluetooth.pin.label"), description: t("bluetooth.pin.description"), - validationText: hasFieldError("fixedPin") - ? getErrorMessage("fixedPin") - : "", - inputChange: bluetoothPinChangeEvent, - disabledBy: [ - { - fieldName: "mode", - selector: Protobuf.Config.Config_BluetoothConfig_PairingMode - .FIXED_PIN, - invert: true, - }, - { - fieldName: "enabled", - }, - ], - properties: { - value: bluetoothPin, - }, }, ], }, diff --git a/src/components/PageComponents/Config/Device/index.tsx b/src/components/PageComponents/Config/Device/index.tsx index 2b53495e..68e4b8ad 100644 --- a/src/components/PageComponents/Config/Device/index.tsx +++ b/src/components/PageComponents/Config/Device/index.tsx @@ -1,4 +1,7 @@ -import type { DeviceValidation } from "@app/validation/config/device.ts"; +import { + type DeviceValidation, + DeviceValidationSchema, +} from "@app/validation/config/device.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -24,6 +27,8 @@ export const Device = () => { return ( onSubmit={onSubmit} + validationSchema={DeviceValidationSchema} + formId="Config_DeviceConfig" defaultValues={config.device} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index 478aec32..0cda0f33 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/src/components/PageComponents/Config/Display.tsx @@ -1,4 +1,7 @@ -import type { DisplayValidation } from "@app/validation/config/display.ts"; +import { + type DisplayValidation, + DisplayValidationSchema, +} from "@app/validation/config/display.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Display = () => { return ( onSubmit={onSubmit} + validationSchema={DisplayValidationSchema} + formId="Config_DisplayConfig" defaultValues={config.display} fieldGroups={[ { @@ -91,7 +96,7 @@ export const Display = () => { label: t("display.oledType.label"), description: t("display.oledType.description"), properties: { - enumValue: Protobuf.Config.Config_Displayjonfig_OledType, + enumValue: Protobuf.Config.Config_DisplayConfig_OledType, }, }, { diff --git a/src/components/PageComponents/Config/LoRa.tsx b/src/components/PageComponents/Config/LoRa.tsx index d8e3b7f7..cf3abdd5 100644 --- a/src/components/PageComponents/Config/LoRa.tsx +++ b/src/components/PageComponents/Config/LoRa.tsx @@ -1,4 +1,7 @@ -import type { LoRaValidation } from "@app/validation/config/lora.ts"; +import { + type LoRaValidation, + LoRaValidationSchema, +} from "@app/validation/config/lora.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const LoRa = () => { return ( onSubmit={onSubmit} + validationSchema={LoRaValidationSchema} + formId="Config_LoRaConfig" defaultValues={config.lora} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Network/index.tsx b/src/components/PageComponents/Config/Network/index.tsx index 9bfb2f54..3d944668 100644 --- a/src/components/PageComponents/Config/Network/index.tsx +++ b/src/components/PageComponents/Config/Network/index.tsx @@ -10,20 +10,12 @@ import { convertIpAddressToInt, } from "@core/utils/ip.ts"; import { Protobuf } from "@meshtastic/core"; -import { validateSchema } from "@app/validation/validate.ts"; import { useTranslation } from "react-i18next"; export const Network = () => { const { config, setWorkingConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); - const onSubmit = (data: NetworkValidation) => { - const result = validateSchema(NetworkValidationSchema, data); - - if (!result.success) { - console.error("Validation errors:", result.errors); - } - setWorkingConfig( create(Protobuf.Config.ConfigSchema, { payloadVariant: { @@ -48,6 +40,8 @@ export const Network = () => { return ( onSubmit={onSubmit} + validationSchema={NetworkValidationSchema} + formId="Config_NetworkConfig" defaultValues={{ ...config.network, ipv4Config: { diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 78841477..b306117c 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -2,7 +2,10 @@ import { type FlagName, usePositionFlags, } from "@core/hooks/usePositionFlags.ts"; -import type { PositionValidation } from "@app/validation/config/position.ts"; +import { + type PositionValidation, + PositionValidationSchema, +} from "@app/validation/config/position.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -41,6 +44,8 @@ export const Position = () => { data.positionFlags = flagsValue; return onSubmit(data); }} + validationSchema={PositionValidationSchema} + formId="Config_PositionConfig" defaultValues={config.position} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Power.tsx b/src/components/PageComponents/Config/Power.tsx index 5044278e..b81e1aaa 100644 --- a/src/components/PageComponents/Config/Power.tsx +++ b/src/components/PageComponents/Config/Power.tsx @@ -1,4 +1,7 @@ -import type { PowerValidation } from "@app/validation/config/power.ts"; +import { + type PowerValidation, + PowerValidationSchema, +} from "@app/validation/config/power.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Power = () => { return ( onSubmit={onSubmit} + validationSchema={PowerValidationSchema} + formId="Config_PowerConfig" defaultValues={config.power} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Security/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx index 533e0d84..5b3f6e77 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -2,116 +2,46 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx" import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts"; -import type { SecurityValidation } from "@app/validation/config/security.ts"; +import { + type ParsedSecurity, + type RawSecurity, + RawSecuritySchema, +} from "@app/validation/config/security.ts"; +import { useState } from "react"; import { create } from "@bufbuild/protobuf"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { fromByteArray, toByteArray } from "base64-js"; -import { useReducer } from "react"; -import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx"; -import type { SecurityConfigInit } from "./types.ts"; import { useTranslation } from "react-i18next"; +type KeyState = { + publicKey: string; + privateKey: string; + privateKeyDialogOpen: boolean; +}; + export const Security = () => { const { config, setWorkingConfig, setDialogOpen } = useDevice(); - const { - hasErrors, - getErrorMessage, - hasFieldError, - addError, - removeError, - clearErrors, - } = useAppStore(); + const { removeError } = useAppStore(); const { t } = useTranslation("deviceConfig"); - const [state, dispatch] = useReducer(securityReducer, { - privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), - privateKeyVisible: false, - adminKeyVisible: [false, false, false], - privateKeyBitCount: config.security?.privateKey?.length ?? 32, - publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)), - adminKey: [ - fromByteArray(config.security?.adminKey?.at(0) ?? new Uint8Array(0)), - fromByteArray(config.security?.adminKey?.at(1) ?? new Uint8Array(0)), - fromByteArray(config.security?.adminKey?.at(2) ?? new Uint8Array(0)), - ], + const [keyState, setKeyState] = useState(() => ({ + publicKey: fromByteArray(config?.security?.publicKey ?? new Uint8Array(0)), + privateKey: fromByteArray( + config?.security?.privateKey ?? new Uint8Array(0), + ), privateKeyDialogOpen: false, - isManaged: config.security?.isManaged ?? false, - adminChannelEnabled: config.security?.adminChannelEnabled ?? false, - debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false, - serialEnabled: config.security?.serialEnabled ?? false, - }); - - const validateKey = ( - input: string, - count: number, - fieldName: "privateKey" | "adminKey", - fieldIndex?: number, - ) => { - const fieldNameKey = fieldName + (fieldIndex ?? ""); - try { - removeError(fieldNameKey); - if (fieldName === "privateKey" && input === "") { - addError(fieldNameKey, t("security.validation.privateKeyRequired")); - return; - } - - if (fieldName === "adminKey" && input === "") { - if ( - state.isManaged && - state.adminKey - .map((v, i) => (i === fieldIndex ? input : v)) - .every((s) => s === "") - ) { - addError("adminKey0", t("security.")); - } - - return; - } - - if (input.length % 4 !== 0) { - addError( - fieldNameKey, - fieldName === "privateKey" - ? t("security.validation.privateKeyMustBe256BitPsk") - : t("security.validation.adminKeyMustBe256BitPsk"), - ); - return; - } + })); - const decoded = toByteArray(input); - if (decoded.length !== count) { - addError( - fieldNameKey, - t("security.validation.enterValid256BitPsk", { - bits: count * 8, - }), - ); - return; - } - } catch (e) { - console.error(e); - addError( - fieldNameKey, - fieldName === "privateKey" - ? t("security.validation.invalidPrivateKeyFormat") - : t("security.validation.invalidAdminKeyFormat"), - ); - } - }; - - function setSecurityPayload(overrides: SecurityConfigInit) { - const base: SecurityConfigInit = { - isManaged: state.isManaged, - adminChannelEnabled: state.adminChannelEnabled, - debugLogApiEnabled: state.debugLogApiEnabled, - serialEnabled: state.serialEnabled, - privateKey: overrides?.privateKey ?? toByteArray(state.privateKey), - publicKey: overrides?.publicKey ?? toByteArray(state.publicKey), + const onSubmit = (data: RawSecurity) => { + const payload: ParsedSecurity = { + ...data, + privateKey: toByteArray(keyState.privateKey), + publicKey: toByteArray(keyState.publicKey), adminKey: [ - overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]), - overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]), - overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]), + toByteArray(data.adminKey.at(0) ?? ""), + toByteArray(data.adminKey.at(1) ?? ""), + toByteArray(data.adminKey.at(2) ?? ""), ], }; @@ -119,137 +49,79 @@ export const Security = () => { create(Protobuf.Config.ConfigSchema, { payloadVariant: { case: "security", - value: { ...base, ...overrides }, + value: payload, }, }), ); - } - + }; const pkiRegenerate = () => { - clearErrors(); const privateKey = getX25519PrivateKey(); - const publicKey = getX25519PublicKey(privateKey); - dispatch({ - type: "REGENERATE_PRIV_PUB_KEY", - payload: { - privateKey: fromByteArray(privateKey), - publicKey: fromByteArray(publicKey), - }, - }); + updatePublicKey(fromByteArray(privateKey)); - validateKey( - fromByteArray(privateKey), - state.privateKeyBitCount, - "privateKey", - ); + setKeyState((prev) => ({ + ...prev, + privateKey: fromByteArray(privateKey), + privateKeyDialogOpen: false, + })); - if (!hasErrors()) { - setSecurityPayload({ - privateKey: privateKey, - publicKey: publicKey, - }); - } + removeError("privateKey"); }; - const privateKeyInputChangeEvent = ( - e: React.ChangeEvent, - ) => { - const privateKeyB64String = e.target.value; - dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String }); - validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey"); - - const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String)); - dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) }); - - if (!hasErrors()) { - setSecurityPayload({ - privateKey: toByteArray(privateKeyB64String), + const updatePublicKey = (privateKey: string) => { + try { + const publicKey = fromByteArray( + getX25519PublicKey(toByteArray(privateKey)), + ); + setKeyState((prev) => ({ + ...prev, + privateKey: privateKey, publicKey: publicKey, - }); - } - }; - - const adminKeyInputChangeEvent = ( - e: React.ChangeEvent, - fieldIndex?: number, - ) => { - if (fieldIndex === undefined) return; - const psk = e.target.value; + })); - const payload = [ - fieldIndex === 0 ? psk : state.adminKey[0], - fieldIndex === 1 ? psk : state.adminKey[1], - fieldIndex === 2 ? psk : state.adminKey[2], - ] satisfies [string, string, string]; - - dispatch({ type: "SET_ADMIN_KEY", payload: payload }); - validateKey(psk, state.privateKeyBitCount, "adminKey", fieldIndex); - - if (!hasErrors()) { - setSecurityPayload({ - adminKey: payload.map(toByteArray) as [ - Uint8Array, - Uint8Array, - Uint8Array, - ], - }); + removeError("publicKey"); + } catch (_e) { + setKeyState((prev) => ({ + ...prev, + privateKey: privateKey, + })); } }; - const onToggleChange = ( - field: - | "isManaged" - | "adminChannelEnabled" - | "debugLogApiEnabled" - | "serialEnabled", - next: boolean, - ) => { - dispatch({ type: "SET_TOGGLE", field, payload: next }); - - if (field === "isManaged" && state.adminKey.every((s) => s === "")) { - if (next) { - // If enabling 'managed' and no admin keys are set - addError( - "adminKey0", - t("security.validation.adminKeyRequiredWhenManaged"), - ); - } else { - removeError("adminKey0"); - removeError("adminKey1"); - removeError("adminKey2"); - } - } - - if (!hasErrors()) { - setSecurityPayload({ - isManaged: field === "isManaged" ? next : state.isManaged, - adminChannelEnabled: field === "adminChannelEnabled" - ? next - : state.adminChannelEnabled, - debugLogApiEnabled: field === "debugLogApiEnabled" - ? next - : state.debugLogApiEnabled, - serialEnabled: field === "serialEnabled" ? next : state.serialEnabled, - }); - } - }; + const bits = [ + { + text: t("security.256bit"), + value: "32", + key: "bit256", + }, + ]; return ( <> - - onSubmit={() => {}} - submitType="onSubmit" + + onSubmit={onSubmit} + validationSchema={RawSecuritySchema} + formId="Config_SecurityConfig" defaultValues={{ ...config.security, ...{ - adminKey: state.adminKey, - privateKey: state.privateKey, - publicKey: state.publicKey, - adminChannelEnabled: config.security?.adminChannelEnabled ?? false, - isManaged: config.security?.isManaged ?? false, - debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false, - serialEnabled: config.security?.serialEnabled ?? false, + privateKey: fromByteArray( + config?.security?.privateKey ?? new Uint8Array(0), + ), + publicKey: fromByteArray( + config?.security?.publicKey ?? new Uint8Array(0), + ), + adminKey: [ + fromByteArray( + config?.security?.adminKey.at(0) ?? new Uint8Array(0), + ), + fromByteArray( + config?.security?.adminKey.at(1) ?? new Uint8Array(0), + ), + fromByteArray( + config?.security?.adminKey.at(2) ?? new Uint8Array(0), + ), + ], }, }} fieldGroups={[ @@ -263,28 +135,20 @@ export const Security = () => { name: "privateKey", label: t("security.privateKey.label"), description: t("security.privateKey.description"), - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - validationText: hasFieldError("privateKey") - ? getErrorMessage("privateKey") - : "", - devicePSKBitCount: state.privateKeyBitCount, - inputChange: privateKeyInputChangeEvent, - selectChange: () => {}, - hide: !state.privateKeyVisible, + bits, + devicePSKBitCount: 32, + hide: true, + inputChange: (e: React.ChangeEvent) => { + updatePublicKey(e.target.value); + }, actionButtons: [ { text: t("button.generate"), onClick: () => - dispatch({ - type: "SHOW_PRIVATE_KEY_DIALOG", - payload: true, - }), + setKeyState((prev) => ({ + ...prev, + privateKeyDialogOpen: true, + })), variant: "success", }, { @@ -294,9 +158,10 @@ export const Security = () => { }, ], properties: { - value: state.privateKey, showCopyButton: true, showPasswordToggle: true, + + value: keyState.privateKey, }, }, { @@ -306,8 +171,8 @@ export const Security = () => { disabled: true, description: t("security.publicKey.description"), properties: { - value: state.publicKey, showCopyButton: true, + value: keyState.publicKey, }, }, ], @@ -322,26 +187,14 @@ export const Security = () => { id: "adminKey0Input", label: t("security.primaryAdminKey.label"), description: t("security.primaryAdminKey.description"), - validationText: hasFieldError("adminKey0") - ? getErrorMessage("adminKey0") - : "", - inputChange: (e) => adminKeyInputChangeEvent(e, 0), - selectChange: () => {}, - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - devicePSKBitCount: state.privateKeyBitCount, - hide: !state.adminKeyVisible[0], + bits, + devicePSKBitCount: 32, + hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { - value: state.adminKey[0], showCopyButton: true, showPasswordToggle: true, }, @@ -352,26 +205,14 @@ export const Security = () => { id: "adminKey1Input", label: t("security.secondaryAdminKey.label"), description: t("security.secondaryAdminKey.description"), - validationText: hasFieldError("adminKey1") - ? getErrorMessage("adminKey1") - : "", - inputChange: (e) => adminKeyInputChangeEvent(e, 1), - selectChange: () => {}, - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - devicePSKBitCount: state.privateKeyBitCount, - hide: !state.adminKeyVisible[1], + bits, + devicePSKBitCount: 32, + hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { - value: state.adminKey[1], showCopyButton: true, showPasswordToggle: true, }, @@ -382,26 +223,14 @@ export const Security = () => { id: "adminKey2Input", label: t("security.tertiaryAdminKey.label"), description: t("security.tertiaryAdminKey.description"), - validationText: hasFieldError("adminKey2") - ? getErrorMessage("adminKey2") - : "", - inputChange: (e) => adminKeyInputChangeEvent(e, 2), - selectChange: () => {}, - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - devicePSKBitCount: state.privateKeyBitCount, - hide: !state.adminKeyVisible[2], + bits, + devicePSKBitCount: 32, + hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { - value: state.adminKey[2], showCopyButton: true, showPasswordToggle: true, }, @@ -411,25 +240,12 @@ export const Security = () => { name: "isManaged", label: t("security.managed.label"), description: t("security.managed.description"), - inputChange: (e: boolean) => onToggleChange("isManaged", e), - properties: { - checked: state.isManaged, - }, - disabled: (hasFieldError("adminKey0") || - hasFieldError("adminKey1") || - hasFieldError("adminKey2")) && - !state.adminKey.every((s) => s === ""), }, { type: "toggle", name: "adminChannelEnabled", label: t("security.adminChannelEnabled.label"), description: t("security.adminChannelEnabled.description"), - inputChange: (e: boolean) => - onToggleChange("adminChannelEnabled", e), - properties: { - checked: state.adminChannelEnabled, - }, }, ], }, @@ -442,21 +258,12 @@ export const Security = () => { name: "debugLogApiEnabled", label: t("security.enableDebugLogApi.label"), description: t("security.enableDebugLogApi.description"), - inputChange: (e: boolean) => - onToggleChange("debugLogApiEnabled", e), - properties: { - checked: state.debugLogApiEnabled, - }, }, { type: "toggle", name: "serialEnabled", label: t("security.serialOutputEnabled.label"), description: t("security.serialOutputEnabled.description"), - inputChange: (e: boolean) => onToggleChange("serialEnabled", e), - properties: { - checked: state.serialEnabled, - }, }, ], }, @@ -468,9 +275,12 @@ export const Security = () => { title: t("pkiRegenerate.title"), description: t("pkiRegenerate.description"), }} - open={state.privateKeyDialogOpen} + open={keyState.privateKeyDialogOpen} onOpenChange={() => - dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })} + setKeyState((prev) => ({ + ...prev, + privateKeyDialogOpen: false, + }))} onSubmit={pkiRegenerate} /> diff --git a/src/components/PageComponents/Config/Security/securityReducer.tsx b/src/components/PageComponents/Config/Security/securityReducer.tsx deleted file mode 100644 index 4860ab4a..00000000 --- a/src/components/PageComponents/Config/Security/securityReducer.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { SecurityAction, SecurityState } from "./types.ts"; - -export function securityReducer( - state: SecurityState, - action: SecurityAction, -): SecurityState { - switch (action.type) { - case "SET_PRIVATE_KEY": - return { ...state, privateKey: action.payload }; - case "SET_PRIVATE_KEY_BIT_COUNT": - return { ...state, privateKeyBitCount: action.payload }; - case "SET_PUBLIC_KEY": - return { ...state, publicKey: action.payload }; - case "SET_ADMIN_KEY": - return { ...state, adminKey: action.payload }; - case "SHOW_PRIVATE_KEY_DIALOG": - return { ...state, privateKeyDialogOpen: action.payload }; - case "REGENERATE_PRIV_PUB_KEY": - return { - ...state, - privateKey: action.payload.privateKey, - publicKey: action.payload.publicKey, - privateKeyDialogOpen: false, - }; - case "SET_TOGGLE": - return { ...state, [action.field]: action.payload }; - default: - return state; - } -} diff --git a/src/components/PageComponents/Config/Security/types.ts b/src/components/PageComponents/Config/Security/types.ts deleted file mode 100644 index acfde8ce..00000000 --- a/src/components/PageComponents/Config/Security/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type MessageInitShape } from "@bufbuild/protobuf"; -import { Protobuf } from "@meshtastic/core"; - -export interface SecurityState { - privateKey: string; - privateKeyVisible: boolean; - adminKeyVisible: [boolean, boolean, boolean]; - privateKeyBitCount: number; - publicKey: string; - adminKey: [string, string, string]; - privateKeyDialogOpen: boolean; - isManaged: boolean; - adminChannelEnabled: boolean; - debugLogApiEnabled: boolean; - serialEnabled: boolean; -} - -export type SecurityAction = - | { type: "SET_PRIVATE_KEY"; payload: string } - | { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number } - | { type: "SET_PUBLIC_KEY"; payload: string } - | { type: "SET_ADMIN_KEY"; payload: [string, string, string] } - | { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean } - | { type: "SET_TOGGLE"; payload: boolean; field: string } - | { - type: "REGENERATE_PRIV_PUB_KEY"; - payload: { privateKey: string; publicKey: string }; - }; - -export type SecurityConfigInit = Extract< - MessageInitShape< - typeof Protobuf.Config.ConfigSchema - >["payloadVariant"], - { case: "security" } ->["value"]; diff --git a/src/components/PageComponents/Map/NodeDetail.tsx b/src/components/PageComponents/Map/NodeDetail.tsx index 12dbe987..516a2fd3 100644 --- a/src/components/PageComponents/Map/NodeDetail.tsx +++ b/src/components/PageComponents/Map/NodeDetail.tsx @@ -22,22 +22,17 @@ import { TooltipProvider, TooltipTrigger, } from "@radix-ui/react-tooltip"; -import { useDevice } from "@core/stores/deviceStore.ts"; -import { - MessageType, - useMessageStore, -} from "@core/stores/messageStore/index.ts"; import BatteryStatus from "@components/BatteryStatus.tsx"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "@tanstack/react-router"; export interface NodeDetailProps { node: ProtobufType.Mesh.NodeInfo; } export const NodeDetail = ({ node }: NodeDetailProps) => { - const { setChatType, setActiveChat } = useMessageStore(); + const navigate = useNavigate(); const { t } = useTranslation("nodes"); - const { setActivePage } = useDevice(); const name = node.user?.longName ?? t("unknown.shortName"); const shortName = node.user?.shortName ?? t("unknown.shortName"); const hwModel = node.user?.hwModel ?? 0; @@ -50,9 +45,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { : rawHardwareType.replaceAll("_", " ") : `${hwModel}`; function handleDirectMessage() { - setChatType(MessageType.Direct); - setActiveChat(node.num); - setActivePage("messages"); + navigate({ to: `/messages/direct/${node.num}` }); } return ( diff --git a/src/components/PageComponents/Messages/MessageItem.tsx b/src/components/PageComponents/Messages/MessageItem.tsx index b36efe13..7a932a1d 100644 --- a/src/components/PageComponents/Messages/MessageItem.tsx +++ b/src/components/PageComponents/Messages/MessageItem.tsx @@ -18,7 +18,7 @@ import { import { Protobuf, Types } from "@meshtastic/core"; import { Message } from "@core/stores/messageStore/types.ts"; import { useTranslation } from "react-i18next"; -// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later +// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented interface MessageStatusInfo { displayText: string; diff --git a/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx b/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx index 405abc8d..51f9cc5d 100644 --- a/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx +++ b/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.ts"; +import { + type AmbientLightingValidation, + AmbientLightingValidationSchema, +} from "@app/validation/moduleConfig/ambientLighting.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -23,6 +26,8 @@ export const AmbientLighting = () => { return ( onSubmit={onSubmit} + validationSchema={AmbientLightingValidationSchema} + formId="ModuleConfig_AmbientLightingConfig" defaultValues={moduleConfig.ambientLighting} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Audio.tsx b/src/components/PageComponents/ModuleConfig/Audio.tsx index a87361e9..4db01275 100644 --- a/src/components/PageComponents/ModuleConfig/Audio.tsx +++ b/src/components/PageComponents/ModuleConfig/Audio.tsx @@ -1,4 +1,7 @@ -import type { AudioValidation } from "@app/validation/moduleConfig/audio.ts"; +import { + type AudioValidation, + AudioValidationSchema, +} from "@app/validation/moduleConfig/audio.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Audio = () => { return ( onSubmit={onSubmit} + validationSchema={AudioValidationSchema} + formId="ModuleConfig_AudioConfig" defaultValues={moduleConfig.audio} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/src/components/PageComponents/ModuleConfig/CannedMessage.tsx index f26f8c51..80221028 100644 --- a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx +++ b/src/components/PageComponents/ModuleConfig/CannedMessage.tsx @@ -1,4 +1,7 @@ -import type { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.ts"; +import { + type CannedMessageValidation, + CannedMessageValidationSchema, +} from "@app/validation/moduleConfig/cannedMessage.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const CannedMessage = () => { return ( onSubmit={onSubmit} + validationSchema={CannedMessageValidationSchema} + formId="ModuleConfig_CannedMessageConfig" defaultValues={moduleConfig.cannedMessage} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx b/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx index 138e6cdc..1ce0aaee 100644 --- a/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx +++ b/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { DetectionSensorValidation } from "@app/validation/moduleConfig/detectionSensor.ts"; +import { + type DetectionSensorValidation, + DetectionSensorValidationSchema, +} from "@app/validation/moduleConfig/detectionSensor.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -23,6 +26,8 @@ export const DetectionSensor = () => { return ( onSubmit={onSubmit} + validationSchema={DetectionSensorValidationSchema} + formId="ModuleConfig_DetectionSensorConfig" defaultValues={moduleConfig.detectionSensor} fieldGroups={[ { @@ -96,17 +101,21 @@ export const DetectionSensor = () => { ], }, { - type: "toggle", - name: "detectionTriggeredHigh", - label: t("detectionSensor.detectionTriggeredHigh.label"), + type: "select", + name: "detectionTriggerType", + label: t("detectionSensor.detectionTriggerType.label"), description: t( - "detectionSensor.detectionTriggeredHigh.description", + "detectionSensor.detectionTriggerType.description", ), disabledBy: [ { fieldName: "enabled", }, ], + properties: { + enumValue: Protobuf.ModuleConfig + .ModuleConfig_DetectionSensorConfig_TriggerType, + }, }, { type: "toggle", diff --git a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx index 470ab5ea..61796342 100644 --- a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx +++ b/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx @@ -1,4 +1,7 @@ -import type { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.ts"; +import { + type ExternalNotificationValidation, + ExternalNotificationValidationSchema, +} from "@app/validation/moduleConfig/externalNotification.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const ExternalNotification = () => { return ( onSubmit={onSubmit} + validationSchema={ExternalNotificationValidationSchema} + formId="ModuleConfig_ExternalNotificationConfig" defaultValues={moduleConfig.externalNotification} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/MQTT.tsx b/src/components/PageComponents/ModuleConfig/MQTT.tsx index 51007f97..e9587e89 100644 --- a/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { MqttValidation } from "@app/validation/moduleConfig/mqtt.ts"; +import { + type MqttValidation, + MqttValidationSchema, +} from "@app/validation/moduleConfig/mqtt.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -29,6 +32,8 @@ export const MQTT = () => { return ( onSubmit={onSubmit} + validationSchema={MqttValidationSchema} + formId="ModuleConfig_MqttConfig" defaultValues={moduleConfig.mqtt} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx b/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx index 9e8c75a8..eabb9207 100644 --- a/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx +++ b/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { NeighborInfoValidation } from "@app/validation/moduleConfig/neighborInfo.ts"; +import { + type NeighborInfoValidation, + NeighborInfoValidationSchema, +} from "@app/validation/moduleConfig/neighborInfo.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -23,6 +26,8 @@ export const NeighborInfo = () => { return ( onSubmit={onSubmit} + validationSchema={NeighborInfoValidationSchema} + formId="ModuleConfig_NeighborInfoConfig" defaultValues={moduleConfig.neighborInfo} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Paxcounter.tsx b/src/components/PageComponents/ModuleConfig/Paxcounter.tsx index d19e398f..c11840ad 100644 --- a/src/components/PageComponents/ModuleConfig/Paxcounter.tsx +++ b/src/components/PageComponents/ModuleConfig/Paxcounter.tsx @@ -1,4 +1,7 @@ -import type { PaxcounterValidation } from "@app/validation/moduleConfig/paxcounter.ts"; +import { + type PaxcounterValidation, + PaxcounterValidationSchema, +} from "@app/validation/moduleConfig/paxcounter.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Paxcounter = () => { return ( onSubmit={onSubmit} + validationSchema={PaxcounterValidationSchema} + formId="ModuleConfig_PaxcounterConfig" defaultValues={moduleConfig.paxcounter} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/src/components/PageComponents/ModuleConfig/RangeTest.tsx index 96787e6e..5960508d 100644 --- a/src/components/PageComponents/ModuleConfig/RangeTest.tsx +++ b/src/components/PageComponents/ModuleConfig/RangeTest.tsx @@ -1,4 +1,7 @@ -import type { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.ts"; +import { + type RangeTestValidation, + RangeTestValidationSchema, +} from "@app/validation/moduleConfig/rangeTest.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const RangeTest = () => { return ( onSubmit={onSubmit} + validationSchema={RangeTestValidationSchema} + formId="ModuleConfig_RangeTestConfig" defaultValues={moduleConfig.rangeTest} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Serial.tsx b/src/components/PageComponents/ModuleConfig/Serial.tsx index f633418b..d98684b5 100644 --- a/src/components/PageComponents/ModuleConfig/Serial.tsx +++ b/src/components/PageComponents/ModuleConfig/Serial.tsx @@ -1,4 +1,7 @@ -import type { SerialValidation } from "@app/validation/moduleConfig/serial.ts"; +import { + type SerialValidation, + SerialValidationSchema, +} from "@app/validation/moduleConfig/serial.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Serial = () => { return ( onSubmit={onSubmit} + validationSchema={SerialValidationSchema} + formId="ModuleConfig_SerialConfig" defaultValues={moduleConfig.serial} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/StoreForward.tsx b/src/components/PageComponents/ModuleConfig/StoreForward.tsx index 6118dad7..02b5d630 100644 --- a/src/components/PageComponents/ModuleConfig/StoreForward.tsx +++ b/src/components/PageComponents/ModuleConfig/StoreForward.tsx @@ -1,4 +1,7 @@ -import type { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.ts"; +import { + type StoreForwardValidation, + StoreForwardValidationSchema, +} from "@app/validation/moduleConfig/storeForward.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const StoreForward = () => { return ( onSubmit={onSubmit} + validationSchema={StoreForwardValidationSchema} + formId="ModuleConfig_StoreForwardConfig" defaultValues={moduleConfig.storeForward} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/src/components/PageComponents/ModuleConfig/Telemetry.tsx index 5515a8d3..d296fe71 100644 --- a/src/components/PageComponents/ModuleConfig/Telemetry.tsx +++ b/src/components/PageComponents/ModuleConfig/Telemetry.tsx @@ -1,4 +1,7 @@ -import type { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.ts"; +import { + type TelemetryValidation, + TelemetryValidationSchema, +} from "@app/validation/moduleConfig/telemetry.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Telemetry = () => { return ( onSubmit={onSubmit} + validationSchema={TelemetryValidationSchema} + formId="ModuleConfig_TelemetryConfig" defaultValues={moduleConfig.telemetry} fieldGroups={[ { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 43dfd431..a55d89e8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -20,6 +20,7 @@ import { useAppStore } from "@core/stores/appStore.ts"; import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx"; import { useTranslation } from "react-i18next"; import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx"; +import { useLocation, useNavigate } from "@tanstack/react-router"; export interface SidebarProps { children?: React.ReactNode; @@ -69,15 +70,19 @@ export const Sidebar = ({ children }: SidebarProps) => { getNode, getNodesLength, metadata, - activePage, unreadCounts, - setActivePage, setDialogOpen, } = useDevice(); const { setCommandPaletteOpen } = useAppStore(); const myNode = getNode(hardware.myNodeNum); const { isCollapsed } = useSidebar(); const { t } = useTranslation("ui"); + const navigate = useNavigate({ from: "/" }); + + const pathname = useLocation({ + select: (location) => location.pathname.replace(/^\//, ""), + }); + const myMetadata = metadata.get(0); const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0); @@ -141,7 +146,7 @@ export const Sidebar = ({ children }: SidebarProps) => { )} > {t("app.logo")} @@ -162,21 +167,23 @@ export const Sidebar = ({ children }: SidebarProps) => { label={t("navigation.title")} className="mt-4 px-0" > - {pages.map((link) => ( - { - if (myNode !== undefined) { - setActivePage(link.page); - } - }} - active={link.page === activePage} - disabled={myNode === undefined} - /> - ))} + {pages.map((link) => { + return ( + { + if (myNode !== undefined) { + navigate({ to: `/${link.page}` }); + } + }} + active={link.page === pathname} + disabled={myNode === undefined} + /> + ); + })}
+
- This is a little embarrassing... + {t("errorPage.title")}

- We are really sorry but an error occurred in the web client that - caused it to crash.
- This is not supposed to happen, and we are working hard to fix it. + {t("errorPage.description1")}

- The best way to prevent this from happening again to you or anyone - else is to report the issue to us. + {t("errorPage.description2")}

Please include the following information in your report:

    -
  • What you were doing when the error occurred
  • -
  • What you expected to happen
  • -
  • What actually happened
  • -
  • Any other relevant information
  • +
  • {t("errorPage.reportSteps.step1")}
  • +
  • {t("errorPage.reportSteps.step2")}
  • +
  • {t("errorPage.reportSteps.step3")}
  • +
  • {t("errorPage.reportSteps.step4")}

- You can report the issue to our{" "} - ", - logs: error?.stack, - })} - > - Github - + ", + logs: error?.stack, + })} + />, + ]} + />

- Return to the dashboard + ]} + />

@@ -60,22 +67,26 @@ export function ErrorPage({ error }: { error: Error }) {
- Error Details + + {t("errorPage.detailsSummary")} + {error?.message && ( <> - +

{error.message}

- + // TODO: Use Trans for the label and message together? )} {error?.stack && ( <> - +

)} {!error?.message && !error?.stack && ( -

{error.toString()}

+

+ {t("errorPage.fallbackError", { error: error.toString() })} +

)}
diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index 213ff10f..2cb22b16 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -1,5 +1,4 @@ -import * as React from "react"; - +import { useEffect, useRef } from "react"; import { Button, type ButtonVariant } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; import { @@ -27,9 +26,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { actionButtons: ActionButton[]; bits?: { text: string; value: string; key: string }[]; selectChange: (event: string) => void; - inputChange: ( - event: React.ChangeEventHandler | undefined, - ) => void; + inputChange: React.ChangeEventHandler; showPasswordToggle?: boolean; showCopyButton?: boolean; disabled?: boolean; @@ -57,10 +54,10 @@ const Generator = ( ...props }: GeneratorProps, ) => { - const inputRef = React.useRef(null); + const inputRef = useRef(null); // Invokes onChange event on the input element when the value changes from the parent component - React.useEffect(() => { + useEffect(() => { if (!inputRef.current) return; const setValue = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, diff --git a/src/components/UI/Typography/Link.tsx b/src/components/UI/Typography/Link.tsx index 5c95a0ab..de477ed9 100644 --- a/src/components/UI/Typography/Link.tsx +++ b/src/components/UI/Typography/Link.tsx @@ -1,14 +1,18 @@ -import { cn } from "../../../core/utils/cn.ts"; +import { cn } from "@core/utils/cn.ts"; +import { + Link as RouterLink, + LinkProps as RouterLinkProps, +} from "@tanstack/react-router"; -export interface LinkProps { +export interface LinkProps extends RouterLinkProps { href: string; - children: React.ReactNode; + children?: React.ReactNode; className?: string; } export const Link = ({ href, children, className }: LinkProps) => ( - ( )} > {children} - + ); diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index e2f1ffe8..d8b7b03d 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -35,7 +35,6 @@ export interface Device { >; nodeErrors: Map; connection?: MeshDevice; - activePage: Page; activeNode: number; waypoints: Protobuf.Mesh.Waypoint[]; pendingSettingsChanges: boolean; @@ -63,7 +62,6 @@ export interface Device { setWorkingConfig: (config: Protobuf.Config.Config) => void; setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void; - setActivePage: (page: Page) => void; setActiveNode: (node: number) => void; setPendingSettingsChanges: (state: boolean) => void; addChannel: (channel: Protobuf.Channel.Channel) => void; @@ -129,7 +127,6 @@ export const useDeviceStore = createStore((set, get) => ({ metadata: new Map(), traceroutes: new Map(), connection: undefined, - activePage: "messages", activeNode: 0, waypoints: [], dialog: { @@ -318,16 +315,6 @@ export const useDeviceStore = createStore((set, get) => ({ }), ); }, - setActivePage: (page) => { - set( - produce((draft) => { - const device = draft.devices.get(id); - if (device) { - device.activePage = page; - } - }), - ); - }, setPendingSettingsChanges: (state) => { set( produce((draft) => { diff --git a/src/core/stores/messageStore/index.ts b/src/core/stores/messageStore/index.ts index 35c92b68..125bf40b 100644 --- a/src/core/stores/messageStore/index.ts +++ b/src/core/stores/messageStore/index.ts @@ -48,8 +48,6 @@ export interface MessageStore { setNodeNum: (nodeNum: number) => void; getMyNodeNum: () => number; - setActiveChat: (chat: number) => void; - setChatType: (type: MessageType) => void; saveMessage: (message: Message) => void; setMessageState: (params: SetMessageStateParams) => void; getMessages: (params: GetMessagesParams) => Message[]; @@ -79,16 +77,6 @@ export const useMessageStore = create()( })); }, getMyNodeNum: () => get().nodeNum, - setActiveChat: (chat) => { - set(produce((state: MessageStore) => { - state.activeChat = chat; - })); - }, - setChatType: (type) => { - set(produce((state: MessageStore) => { - state.chatType = type; - })); - }, saveMessage: (message: Message) => { set( produce((state: MessageStore) => { diff --git a/src/core/stores/messageStore/messageStore.test.ts b/src/core/stores/messageStore/messageStore.test.ts index 13584dbb..fa43eceb 100644 --- a/src/core/stores/messageStore/messageStore.test.ts +++ b/src/core/stores/messageStore/messageStore.test.ts @@ -122,13 +122,6 @@ describe("useMessageStore", () => { expect(useMessageStore.getState().nodeNum).toBe(myNodeNum); }); - it("should set activeChat and chatType", () => { - useMessageStore.getState().setActiveChat(otherNodeNum1); - useMessageStore.getState().setChatType(MessageType.Direct); - expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1); - expect(useMessageStore.getState().chatType).toBe(MessageType.Direct); - }); - describe("saveMessage", () => { it("should save a direct message with correct Map structure", () => { useMessageStore.getState().saveMessage(directMessageToOther1); diff --git a/src/core/utils/dotPath.test.ts b/src/core/utils/dotPath.test.ts new file mode 100644 index 00000000..801a6637 --- /dev/null +++ b/src/core/utils/dotPath.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { dotPaths } from "./dotPath.ts"; + +describe("dotPaths", () => { + it("returns flat keys for a simple object", () => { + const obj = { a: 1, b: 2, c: 3 }; + expect(dotPaths(obj)).toEqual(["a", "b", "c"]); + }); + + it("returns dot notation keys for nested objects", () => { + const obj = { a: { b: { c: 1 } }, d: 2 }; + expect(dotPaths(obj)).toEqual(["a.b.c", "d"]); + }); + + it("handles arrays at the root", () => { + const arr = [{ x: 1 }, { y: 2 }]; + expect(dotPaths(arr)).toEqual(["0.x", "1.y"]); + }); + + it("handles arrays nested in objects", () => { + const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 }; + expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]); + }); + + it("handles objects nested in arrays", () => { + const arr = [{ a: { b: 1 } }, { c: 2 }]; + expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]); + }); + + it("handles primitive values in arrays", () => { + const arr = [1, { a: 2 }, 3]; + expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]); + }); + + it("handles empty objects and arrays", () => { + expect(dotPaths({})).toEqual([]); + expect(dotPaths([])).toEqual([]); + }); + + it("handles mixed nested structures", () => { + const obj = { + a: [ + { b: 1, c: [2, 3] }, + { d: { e: 4 } }, + ], + f: 5, + }; + expect(dotPaths(obj)).toEqual([ + "a.0.b", + "a.0.c.0", + "a.0.c.1", + "a.1.d.e", + "f", + ]); + }); + + it("handles prefix argument", () => { + const obj = { a: { b: 1 } }; + expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]); + }); + + it("skips null and undefined values", () => { + const obj = { a: null, b: undefined, c: { d: 1 } }; + expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]); + }); +}); diff --git a/src/core/utils/dotPath.ts b/src/core/utils/dotPath.ts new file mode 100644 index 00000000..85035500 --- /dev/null +++ b/src/core/utils/dotPath.ts @@ -0,0 +1,19 @@ +export type DotPath = { [key: string]: unknown } | unknown[]; + +export const dotPaths = ( + obj: T, + prefix = "", +): string[] => { + if (Array.isArray(obj)) { + return obj.flatMap((v, i) => + v && typeof v === "object" + ? dotPaths(v as DotPath, `${prefix}${i}.`) + : [`${prefix}${i}`] + ); + } + return Object.entries(obj).flatMap(([k, v]) => + v && typeof v === "object" + ? dotPaths(v as DotPath, `${prefix}${k}.`) + : [`${prefix}${k}`] + ); +}; diff --git a/src/core/utils/test.tsx b/src/core/utils/test.tsx index 9169fe7e..cb9906ae 100644 --- a/src/core/utils/test.tsx +++ b/src/core/utils/test.tsx @@ -1,12 +1,80 @@ -import { render } from "@testing-library/react"; -import type { ReactElement } from "react"; +import { + createMemoryHistory, + createRouter, + Outlet, + RootRoute, + Route, + RouterProvider, +} from "@tanstack/react-router"; +import { render as rtlRender, RenderOptions } from "@testing-library/react"; +import type { FunctionComponent, ReactElement, ReactNode } from "react"; -function customRender(ui: ReactElement, options = {}) { - return render(ui, { - // wrapper: ({ children }) => {children}, - ...options, - }); +// a root route for the test router. +const rootRoute = new RootRoute({ + component: () => ( + <> + + + ), +}); + +interface CustomRenderOptions extends Omit { + initialEntries?: string[]; + ui?: ReactElement; } +let currentRouter: ReturnType | null = null; + +/** + * Custom render function for testing components that need TanStack Router context. + * @param ui The main ReactElement to render (your component under test). + * @param options Custom render options including initialEntries for the router. + * @returns An object containing the testing-library render result and the router instance. + */ +const customRender = ( + ui: ReactElement, + options: CustomRenderOptions = {}, +) => { + const { initialEntries = ["/"], ...renderOptions } = options; + + // A specific route that renders the component under test (ui). + // It defaults to the first path in initialEntries or '/'. + const testComponentRoute = new Route({ + getParentRoute: () => rootRoute, + path: initialEntries[0] || "/", + component: () => ui, // The component passed to render will be the element for this route + }); + + const routeTree = rootRoute.addChildren([testComponentRoute]); + + const router = createRouter({ + history: createMemoryHistory({ initialEntries }), + routeTree, + // You can add default error components or other router options if needed for tests. + // defaultErrorComponent: ({ error }) =>
Test Error: {error.message}
, + }); + + currentRouter = router; // Store the router instance for access in tests + + const Wrapper: FunctionComponent<{ children?: ReactNode }> = ( + { children }, + ) => { + return ( + <> + + {children} + + ); + }; + + const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); + + return { + ...renderResult, + router, + }; +}; + export * from "@testing-library/react"; export { customRender as render }; +export const getTestRouter = () => currentRouter; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index b54b8f88..efb44b14 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -69,5 +69,34 @@ }, "nodeUnknownPrefix": "!", "unset": "UNSET", - "fallbackName": "Meshtastic {{last4}}" + "fallbackName": "Meshtastic {{last4}}", + "formValidation": { + "tooBig": { + "string": "Too long, expected less than or equal to {{maximum}} characters.", + "number": "Too big, expected a number smaller than or equal to {{maximum}}.", + "bytes": "Too big, expected less than or equal to {{params.maximum}} bytes." + }, + "tooSmall": { + "string": "Too short, expected more than or equal to {{minimum}} characters.", + "number": "Too small, expected a number larger than or equal to {{minimum}}." + }, + "invalidFormat": { + "ipv4": "Invalid format, expected an IPv4 address.", + "key": "Invalid format, expected a Base64 encoded pre-shared key (PSK)." + }, + "invalidType": { + "number": "Invalid type, expected a number." + }, + "pskLength": { + "0bit": "Key is required to be empty.", + "8bit": "Key is required to be an 8 bit pre-shared key (PSK).", + "128bit": "Key is required to be a 128 bit pre-shared key (PSK).", + "256bit": "Key is required to be a 256 bit pre-shared key (PSK)." + }, + "required": { + "generic": "This field is required.", + "managed": "At least one admin key is requred if the node is managed.", + "key": "Key is required." + } + } } diff --git a/src/i18n/locales/en/deviceConfig.json b/src/i18n/locales/en/deviceConfig.json index 0fc9314e..193c5494 100644 --- a/src/i18n/locales/en/deviceConfig.json +++ b/src/i18n/locales/en/deviceConfig.json @@ -68,11 +68,6 @@ "pin": { "description": "Pin to use when pairing", "label": "Pin" - }, - "validation": { - "pinCannotStartWithZero": "Bluetooth Pin cannot start with 0", - "pinMustBeSixDigits": "Pin must be 6 digits", - "pinRequired": "Bluetooth Pin is required" } }, "display": { @@ -428,15 +423,6 @@ "loggingSettings": { "description": "Settings for Logging", "label": "Logging Settings" - }, - "validation": { - "adminKeyMustBe256BitPsk": "Admin Key is required to be a 256 bit pre-shared key (PSK)", - "adminKeyRequiredWhenManaged": "At least one admin key is requred if the node is managed.", - "enterValid256BitPsk": "Please enter a valid 256 bit PSK", - "invalidAdminKeyFormat": "Invalid Admin Key format", - "invalidPrivateKeyFormat": "Invalid Private Key format", - "privateKeyMustBe256BitPsk": "Private Key is required to be a 256 bit pre-shared key (PSK)", - "privateKeyRequired": "Private Key is required" } } } diff --git a/src/i18n/locales/en/messages.json b/src/i18n/locales/en/messages.json index 40ba3394..4df0ac92 100644 --- a/src/i18n/locales/en/messages.json +++ b/src/i18n/locales/en/messages.json @@ -9,6 +9,10 @@ "selectChatPrompt": { "text": "Select a channel or node to start messaging." }, + "sendMessage": { + "placeholder": "Type your message here...", + "sendButton": "Send" + }, "actionsMenu": { "addReactionLabel": "Add Reaction", "replyLabel": "Reply" diff --git a/src/i18n/locales/en/moduleConfig.json b/src/i18n/locales/en/moduleConfig.json index dedf5654..bca3468e 100644 --- a/src/i18n/locales/en/moduleConfig.json +++ b/src/i18n/locales/en/moduleConfig.json @@ -144,9 +144,9 @@ "label": "Monitor Pin", "description": "The GPIO pin to monitor for state changes" }, - "detectionTriggeredHigh": { - "label": "Detection Triggered High", - "description": "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)" + "detectionTriggerType": { + "label": "Detection Triggered Type", + "description": "The type of trigger event to be used" }, "usePullup": { "label": "Use Pullup", diff --git a/src/i18n/locales/en/ui.json b/src/i18n/locales/en/ui.json index 292f6927..6f0b6aa3 100644 --- a/src/i18n/locales/en/ui.json +++ b/src/i18n/locales/en/ui.json @@ -156,6 +156,24 @@ "system": "Automatic", "changeTheme": "Change Color Scheme" }, + "errorPage": { + "title": "This is a little embarrassing...", + "description1": "We are really sorry but an error occurred in the web client that caused it to crash.
This is not supposed to happen, and we are working hard to fix it.", + "description2": "The best way to prevent this from happening again to you or anyone else is to report the issue to us.", + "reportInstructions": "Please include the following information in your report:", + "reportSteps": { + "step1": "What you were doing when the error occurred", + "step2": "What you expected to happen", + "step3": "What actually happened", + "step4": "Any other relevant information" + }, + "reportLink": "You can report the issue to our <0>GitHub", + "dashboardLink": "Return to the <0>dashboard", + "detailsSummary": "Error Details", + "errorMessageLabel": "Error message:", + "stackTraceLabel": "Stack trace:", + "fallbackError": "{{error}}" + }, "footer": { "text": "Powered by <0>▲ Vercel | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information", "commitSha": "Commit SHA: {{sha}}" diff --git a/src/index.tsx b/src/index.tsx index 65f4ed87..dbaafd39 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,19 +3,29 @@ import { enableMapSet } from "immer"; import "maplibre-gl/dist/maplibre-gl.css"; import { StrictMode, Suspense } from "react"; import { createRoot } from "react-dom/client"; - -import { App } from "@app/App.tsx"; import "./i18n/config.ts"; +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { routeTree } from "@app/routes.tsx"; + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} const container = document.getElementById("root") as HTMLElement; const root = createRoot(container); enableMapSet(); +const router = createRouter({ + routeTree, +}); + root.render( - - , + + , ); diff --git a/src/pages/Messages.tsx b/src/pages/Messages.tsx index 13f9d11c..95d167b9 100644 --- a/src/pages/Messages.tsx +++ b/src/pages/Messages.tsx @@ -9,7 +9,13 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf, Types } from "@meshtastic/core"; import { getChannelName } from "@pages/Channels.tsx"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; -import { useCallback, useDeferredValue, useMemo, useState } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useState, +} from "react"; import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx"; import { cn } from "@core/utils/cn.ts"; import { @@ -21,6 +27,7 @@ import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { Input } from "@components/UI/Input.tsx"; import { randId } from "@core/utils/randId.ts"; import { useTranslation } from "react-i18next"; +import { useNavigate, useParams } from "@tanstack/react-router"; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; @@ -37,18 +44,45 @@ export const MessagesPage = () => { const { getMyNodeNum, getMessages, - setActiveChat, - chatType, - activeChat, - setChatType, setMessageState, } = useMessageStore(); + const params = useParams({ from: "", shouldThrow: false }); + const navigate = useNavigate(); const { toast } = useToast(); const { isCollapsed } = useSidebar(); const [searchTerm, setSearchTerm] = useState(""); const { t } = useTranslation(["messages", "channels", "ui"]); const deferredSearch = useDeferredValue(searchTerm); + const chatType = params.type === "direct" + ? MessageType.Direct + : params.type === "broadcast" + ? MessageType.Broadcast + : undefined; + const activeChat = params.chatId ? Number(params.chatId) : undefined; + + const allChannels = Array.from(channels.values()); + const filteredChannels = allChannels.filter( + (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, + ); + const currentChannel = channels.get(activeChat); + const otherNode = getNode(activeChat); + + const isDirect = chatType === MessageType.Direct; + const isBroadcast = chatType === MessageType.Broadcast; + + const navigateToChat = useCallback((type: MessageType, chatId: number) => { + const typeParam = type === MessageType.Direct ? "direct" : "broadcast"; + navigate({ to: `/messages/${typeParam}/${chatId}` }); + }, [navigate]); + + useEffect(() => { + if (!params.type && !params.chatId && filteredChannels.length > 0) { + const defaultChannel = filteredChannels[0]; + navigateToChat(MessageType.Broadcast, defaultChannel.index); + } + }, [params.type, params.chatId, filteredChannels, navigateToChat]); + const filteredNodes = (): NodeInfoWithUnread[] => { const lowerCaseSearchTerm = deferredSearch.toLowerCase(); @@ -69,16 +103,6 @@ export const MessagesPage = () => { }); }; - const allChannels = Array.from(channels.values()); - const filteredChannels = allChannels.filter( - (ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED, - ); - const currentChannel = channels.get(activeChat); - const otherNode = getNode(activeChat); - - const isDirect = chatType === MessageType.Direct; - const isBroadcast = chatType === MessageType.Broadcast; - const sendText = useCallback(async (message: string) => { const isDirect = chatType === MessageType.Direct; const toValue = isDirect ? activeChat : MessageType.Broadcast; @@ -116,10 +140,8 @@ export const MessagesPage = () => { } else { console.warn("sendText completed but messageId is undefined"); } - // deno-lint-ignore no-explicit-any } catch (e: any) { console.error("Failed to send message:", e); - // Note: messageId might be undefined here if the error occurred before it was assigned const failedId = messageId ?? randId(); if (chatType === MessageType.Broadcast) { setMessageState({ @@ -165,7 +187,7 @@ export const MessagesPage = () => { default: return (
- {t("messagesPage.selectChatPrompt")} + {t("selectChatPrompt.text", { ns: "messages" })}
); } @@ -191,8 +213,7 @@ export const MessagesPage = () => { active={activeChat === channel.index && chatType === MessageType.Broadcast} onClick={() => { - setChatType(MessageType.Broadcast); - setActiveChat(channel.index); + navigateToChat(MessageType.Broadcast, channel.index); resetUnread(channel.index); }} > @@ -210,8 +231,7 @@ export const MessagesPage = () => { activeChat, chatType, isCollapsed, - setActiveChat, - setChatType, + navigateToChat, resetUnread, ]); @@ -245,8 +265,7 @@ export const MessagesPage = () => { active={activeChat === node.num && chatType === MessageType.Direct} onClick={() => { - setChatType(MessageType.Direct); - setActiveChat(node.num); + navigateToChat(MessageType.Direct, node.num); resetUnread(node.num); }} > @@ -268,8 +287,7 @@ export const MessagesPage = () => { searchTerm, activeChat, chatType, - setActiveChat, - setChatType, + navigateToChat, resetUnread, hasNodeError, ], @@ -319,7 +337,7 @@ export const MessagesPage = () => { ) : (
- {t("messagesPage.sendMessagePrompt")} + {t("sendMessage.sendButton", { ns: "messages" })}
)} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts new file mode 100644 index 00000000..78c69447 --- /dev/null +++ b/src/routeTree.gen.ts @@ -0,0 +1,59 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +// Import Routes + +import { Route as rootRoute } from './routes/__root' + +// Create/Update Routes + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath {} +} + +// Create and export the route tree + +export interface FileRoutesByFullPath {} + +export interface FileRoutesByTo {} + +export interface FileRoutesById { + __root__: typeof rootRoute +} + +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: never + fileRoutesByTo: FileRoutesByTo + to: never + id: '__root__' + fileRoutesById: FileRoutesById +} + +export interface RootRouteChildren {} + +const rootRouteChildren: RootRouteChildren = {} + +export const routeTree = rootRoute + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [] + } + } +} +ROUTE_MANIFEST_END */ diff --git a/src/routes.tsx b/src/routes.tsx new file mode 100644 index 00000000..f936fd12 --- /dev/null +++ b/src/routes.tsx @@ -0,0 +1,79 @@ +import { createRoute, redirect } from "@tanstack/react-router"; +import { Dashboard } from "@pages/Dashboard/index.tsx"; +import MessagesPage from "@pages/Messages.tsx"; +import MapPage from "@pages/Map/index.tsx"; +import ConfigPage from "@pages/Config/index.tsx"; +import ChannelsPage from "@pages/Channels.tsx"; +import NodesPage from "@pages/Nodes.tsx"; +import { createRootRoute } from "@tanstack/react-router"; +import { App } from "./App.tsx"; +import { DialogManager } from "@components/Dialog/DialogManager.tsx"; + +const rootRoute = createRootRoute({ + component: App, +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: Dashboard, + loader: () => { + // Redirect to the broadcast messages page on initial load + return redirect({ to: `/messages/broadcast/0`, replace: true }); + }, +}); + +const messagesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/messages", + component: MessagesPage, +}); + +const messagesWithParamsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/messages/$type/$chatId", + component: MessagesPage, +}); + +const mapRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/map", + component: MapPage, +}); + +const configRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/config", + component: ConfigPage, +}); + +const channelsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/channels", + component: ChannelsPage, +}); + +const nodesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/nodes", + component: NodesPage, +}); + +const dialogWithParamsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/dialog/$dialogId", + component: DialogManager, +}); + +export const routeTree = rootRoute.addChildren([ + indexRoute, + messagesRoute, + messagesWithParamsRoute, + mapRoute, + configRoute, + channelsRoute, + nodesRoute, + dialogWithParamsRoute, +]); + +export { rootRoute }; diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index 37f1f3c1..4211fb77 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -5,16 +5,28 @@ import "@testing-library/jest-dom"; import "@testing-library/user-event"; import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import channelsEN from "@app/i18n/locales/en/channels.json"; -import commandPaletteEN from "@app/i18n/locales/en/commandPalette.json"; -import commonEN from "@app/i18n/locales/en/common.json"; -import deviceConfigEN from "@app/i18n/locales/en/deviceConfig.json"; -import moduleConfigEN from "@app/i18n/locales/en/moduleConfig.json"; -import dashboardEN from "@app/i18n/locales/en/dashboard.json"; -import dialogEN from "@app/i18n/locales/en/dialog.json"; -import messagesEN from "@app/i18n/locales/en/messages.json"; -import nodesEN from "@app/i18n/locales/en/nodes.json"; -import uiEN from "@app/i18n/locales/en/ui.json"; +import channelsEN from "@app/i18n/locales/en/channels.json" with { + type: "json", +}; +import commandPaletteEN from "@app/i18n/locales/en/commandPalette.json" with { + type: "json", +}; +import commonEN from "@app/i18n/locales/en/common.json" with { type: "json" }; +import deviceConfigEN from "@app/i18n/locales/en/deviceConfig.json" with { + type: "json", +}; +import moduleConfigEN from "@app/i18n/locales/en/moduleConfig.json" with { + type: "json", +}; +import dashboardEN from "@app/i18n/locales/en/dashboard.json" with { + type: "json", +}; +import dialogEN from "@app/i18n/locales/en/dialog.json" with { type: "json" }; +import messagesEN from "@app/i18n/locales/en/messages.json" with { + type: "json", +}; +import nodesEN from "@app/i18n/locales/en/nodes.json" with { type: "json" }; +import uiEN from "@app/i18n/locales/en/ui.json" with { type: "json" }; enableMapSet(); diff --git a/src/validation/channel.test.ts b/src/validation/channel.test.ts new file mode 100644 index 00000000..74ce1146 --- /dev/null +++ b/src/validation/channel.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { makeChannelSchema } from "./channel.ts"; +import { fromByteArray } from "base64-js"; + +const mockRole = 0; + +function makeBase64OfLength(len: number): string { + return fromByteArray(new Uint8Array(len)); +} + +describe("makeChannelSchema", () => { + const allowedBytes = 16; + const schema = makeChannelSchema(allowedBytes); + + const validBase64 = makeBase64OfLength(allowedBytes); + + const validSettings = { + channelNum: 3, + psk: validBase64, + name: "TestName", + id: 3, + uplinkEnabled: true, + downlinkEnabled: false, + moduleSettings: { positionPrecision: 10 }, + }; + + it("accepts valid channel object", () => { + const result = schema.safeParse({ + index: 0, + settings: validSettings, + role: mockRole, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid base64 psk", () => { + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, psk: "not_base64!" }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("psk") + ), + ).toBe(true); + } + }); + + it("rejects psk of wrong length", () => { + const wrongLength = makeBase64OfLength(8); + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, psk: wrongLength }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("psk") + ), + ).toBe(true); + } + }); + + it("rejects name longer than 12 bytes", () => { + const longName = "a".repeat(13); + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, name: longName }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("name") + ), + ).toBe(true); + } + }); + + it("rejects channelNum out of range", () => { + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, channelNum: 10 }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("channelNum") + ), + ).toBe(true); + } + }); + + it("rejects missing required fields", () => { + const result = schema.safeParse({ + index: 0, + settings: {}, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + + it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => { + for (const val of [0, 10, 15, 19, 32]) { + const result = schema.safeParse({ + index: 0, + settings: { + ...validSettings, + moduleSettings: { positionPrecision: val }, + }, + role: mockRole, + }); + expect(result.success).toBe(true); + } + }); + + it("rejects moduleSettings.positionPrecision out of range", () => { + for (const val of [9, 20, 31, 33]) { + const result = schema.safeParse({ + index: 0, + settings: { + ...validSettings, + moduleSettings: { positionPrecision: val }, + }, + role: mockRole, + }); + expect(result.success).toBe(false); + } + }); +}); diff --git a/src/validation/channel.ts b/src/validation/channel.ts index 4f4d43bb..37fe50f5 100644 --- a/src/validation/channel.ts +++ b/src/validation/channel.ts @@ -1,51 +1,38 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { - IsBoolean, - IsEnum, - IsInt, - IsNumber, - IsString, - Length, -} from "class-validator"; - -export class ChannelValidation - implements Omit { - @IsNumber() - index: number; - - settings: Channel_SettingsValidation; - - @IsEnum(Protobuf.Channel.Channel_Role) - role: Protobuf.Channel.Channel_Role; -} - -export class Channel_SettingsValidation - implements Omit { - @IsNumber() - channelNum: number; - - @IsString() - psk: string; - - @Length(0, 11) - name: string; - - @IsInt() - id: number; - - @IsBoolean() - uplinkEnabled: boolean; - - @IsBoolean() - downlinkEnabled: boolean; - - @IsBoolean() - positionEnabled: boolean; - - @IsBoolean() - preciseLocation: boolean; - - @IsInt() - positionPrecision: number; +import { makePskHelpers } from "./pskSchema.ts"; +import { validateMaxByteLength } from "@core/utils/string.ts"; + +const RoleEnum = z.enum(Protobuf.Channel.Channel_Role); + +const moduleSettingsSchema = z.object({ + positionPrecision: z.union([ + z.literal(0), + z.coerce.number().int().min(10).max(19), + z.literal(32), + ]), +}); + +export function makeChannelSchema(allowedBytes: number) { + const { stringSchema } = makePskHelpers([allowedBytes]); + + const ChannelSettingsSchema = z.object({ + channelNum: z.coerce.number().int().min(0).max(7), + psk: stringSchema(false), + name: z.string() + .refine( + (s) => validateMaxByteLength(s, 12).isValid, + { message: "formValidation.tooBig.bytes", params: { maximum: 12 } }, + ), + id: z.coerce.number().int(), + uplinkEnabled: z.boolean(), + downlinkEnabled: z.boolean(), + moduleSettings: moduleSettingsSchema, + }); + + return z.object({ + index: z.coerce.number(), + settings: ChannelSettingsSchema, + role: RoleEnum, + }); } diff --git a/src/validation/config/bluetooth.ts b/src/validation/config/bluetooth.ts index 67492240..1c4ce359 100644 --- a/src/validation/config/bluetooth.ts +++ b/src/validation/config/bluetooth.ts @@ -1,18 +1,14 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class BluetoothValidation implements - Omit< - Protobuf.Config.Config_BluetoothConfig, - keyof Message | "deviceLoggingEnabled" - > { - @IsBoolean() - enabled: boolean; +const PairingModeEnum = z.enum( + Protobuf.Config.Config_BluetoothConfig_PairingMode, +); - @IsEnum(Protobuf.Config.Config_BluetoothConfig_PairingMode) - mode: Protobuf.Config.Config_BluetoothConfig_PairingMode; +export const BluetoothValidationSchema = z.object({ + enabled: z.boolean(), + mode: PairingModeEnum, + fixedPin: z.coerce.number().int().min(100000).max(999999), +}); - @IsInt() - fixedPin: number; -} +export type BluetoothValidation = z.infer; diff --git a/src/validation/config/device.ts b/src/validation/config/device.ts index 0858e6a7..21ce6d74 100644 --- a/src/validation/config/device.ts +++ b/src/validation/config/device.ts @@ -1,42 +1,25 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator"; -export class DeviceValidation - implements Omit { - @IsEnum(Protobuf.Config.Config_DeviceConfig_Role) - role: Protobuf.Config.Config_DeviceConfig_Role; - - @IsBoolean() - serialEnabled: boolean; - - @IsBoolean() - debugLogEnabled: boolean; - - @IsInt() - buttonGpio: number; - - @IsInt() - buzzerGpio: number; - - @IsEnum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode) - rebroadcastMode: Protobuf.Config.Config_DeviceConfig_RebroadcastMode; - - @IsInt() - nodeInfoBroadcastSecs: number; - - @IsBoolean() - doubleTapAsButtonPress: boolean; - - @IsBoolean() - isManaged: boolean; - - @IsBoolean() - disableTripleClick: boolean; - - @IsBoolean() - ledHeartbeatDisabled: boolean; - - @IsString() - tzdef: string; -} +const RoleEnum = z.enum( + Protobuf.Config.Config_DeviceConfig_Role, +); +const RebroadcastModeEnum = z.enum( + Protobuf.Config.Config_DeviceConfig_RebroadcastMode, +); + +export const DeviceValidationSchema = z.object({ + role: RoleEnum, + serialEnabled: z.boolean(), + buttonGpio: z.coerce.number().int().min(0), + buzzerGpio: z.coerce.number().int().min(0), + rebroadcastMode: RebroadcastModeEnum, + nodeInfoBroadcastSecs: z.coerce.number().int().min(0), + doubleTapAsButtonPress: z.boolean(), + isManaged: z.boolean(), + disableTripleClick: z.boolean(), + ledHeartbeatDisabled: z.boolean(), + tzdef: z.string().max(65), +}); + +export type DeviceValidation = z.infer; diff --git a/src/validation/config/display.ts b/src/validation/config/display.ts index 6f38164b..c3fea8ee 100644 --- a/src/validation/config/display.ts +++ b/src/validation/config/display.ts @@ -1,39 +1,35 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class DisplayValidation - implements Omit { - @IsInt() - screenOnSecs: number; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat) - gpsFormat: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat; - - @IsInt() - autoScreenCarouselSecs: number; - - @IsBoolean() - compassNorthTop: boolean; - - @IsBoolean() - flipScreen: boolean; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayUnits) - units: Protobuf.Config.Config_DisplayConfig_DisplayUnits; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_OledType) - oled: Protobuf.Config.Config_DisplayConfig_OledType; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayMode) - displaymode: Protobuf.Config.Config_DisplayConfig_DisplayMode; - - @IsBoolean() - headingBold: boolean; - - @IsBoolean() - wakeOnTapOrMotion: boolean; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation) - compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation; -} +const GpsCoordinateEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat, +); +const DisplayUnitsEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_DisplayUnits, +); +const OledTypeEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_OledType, +); +const DisplayModeEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_DisplayMode, +); +const CompassOrientationEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_CompassOrientation, +); + +export const DisplayValidationSchema = z.object({ + screenOnSecs: z.coerce.number().int().min(0), + gpsFormat: GpsCoordinateEnum, + autoScreenCarouselSecs: z.coerce.number().int().min(0), + compassNorthTop: z.boolean(), + flipScreen: z.boolean(), + units: DisplayUnitsEnum, + oled: OledTypeEnum, + displaymode: DisplayModeEnum, + headingBold: z.boolean(), + wakeOnTapOrMotion: z.boolean(), + compassOrientation: CompassOrientationEnum, + use12hClock: z.boolean(), +}); + +export type DisplayValidation = z.infer; diff --git a/src/validation/config/lora.ts b/src/validation/config/lora.ts index ad70b586..7511bccd 100644 --- a/src/validation/config/lora.ts +++ b/src/validation/config/lora.ts @@ -1,65 +1,31 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator"; -export class LoRaValidation - implements - Omit { - @IsBoolean() - usePreset: boolean; - - @IsEnum(Protobuf.Config.Config_LoRaConfig_ModemPreset) - modemPreset: Protobuf.Config.Config_LoRaConfig_ModemPreset; - - @IsInt() - bandwidth: number; - - @IsInt() - // @Min(7) - @Max(12) - spreadFactor: number; - - @IsInt() - @Min(0) - @Max(10) - codingRate: number; - - @IsInt() - frequencyOffset: number; - - @IsEnum(Protobuf.Config.Config_LoRaConfig_RegionCode) - region: Protobuf.Config.Config_LoRaConfig_RegionCode; - - @IsInt() - @Min(1) - @Max(7) - hopLimit: number; - - @IsBoolean() - txEnabled: boolean; - - @IsInt() - @Min(0) - txPower: number; - - @IsInt() - channelNum: number; - - @IsBoolean() - overrideDutyCycle: boolean; - - @IsBoolean() - sx126xRxBoostedGain: boolean; - - @IsInt() - overrideFrequency: number; - - @IsArray() - ignoreIncoming: number[]; - - @IsBoolean() - ignoreMqtt: boolean; - - @IsBoolean() - configOkToMqtt: boolean; -} +const ModemPresetEnum = z.enum( + Protobuf.Config.Config_LoRaConfig_ModemPreset, +); +const RegionCodeEnum = z.enum( + Protobuf.Config.Config_LoRaConfig_RegionCode, +); + +export const LoRaValidationSchema = z.object({ + usePreset: z.boolean(), + modemPreset: ModemPresetEnum, + bandwidth: z.coerce.number().int(), + spreadFactor: z.coerce.number().int().max(12), + codingRate: z.coerce.number().int().min(0).max(10), + frequencyOffset: z.coerce.number().int(), + region: RegionCodeEnum, + hopLimit: z.coerce.number().int().min(0).max(7), + txEnabled: z.boolean(), + txPower: z.coerce.number().int().min(0), + channelNum: z.coerce.number().int(), + overrideDutyCycle: z.boolean(), + sx126xRxBoostedGain: z.boolean(), + overrideFrequency: z.coerce.number().int(), + ignoreIncoming: z.coerce.number().array(), + ignoreMqtt: z.boolean(), + configOkToMqtt: z.boolean(), +}); + +export type LoRaValidation = z.infer; diff --git a/src/validation/config/network.ts b/src/validation/config/network.ts index e58fb94d..26ba9c36 100644 --- a/src/validation/config/network.ts +++ b/src/validation/config/network.ts @@ -1,30 +1,30 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -const AddressModeEnum = z.nativeEnum( +const AddressModeEnum = z.enum( Protobuf.Config.Config_NetworkConfig_AddressMode, ); -const ProtocolFlagsEnum = z.nativeEnum( +const ProtocolFlagsEnum = z.enum( Protobuf.Config.Config_NetworkConfig_ProtocolFlags, ); export const NetworkValidationIpV4ConfigSchema = z.object({ - ip: z.string().ip(), - gateway: z.string().ip(), - subnet: z.string().ip(), - dns: z.string().ip(), + ip: z.ipv4(), + gateway: z.ipv4(), + subnet: z.ipv4(), + dns: z.ipv4(), }); export const NetworkValidationSchema = z.object({ wifiEnabled: z.boolean(), - wifiSsid: z.string().min(0).max(33).optional(), - wifiPsk: z.string().min(0).max(64).optional(), - ntpServer: z.string().min(2).max(30), + wifiSsid: z.string().max(33), + wifiPsk: z.string().max(64), + ntpServer: z.string().min(2).max(33), ethEnabled: z.boolean(), addressMode: AddressModeEnum, - ipv4Config: NetworkValidationIpV4ConfigSchema.optional(), + ipv4Config: NetworkValidationIpV4ConfigSchema, enabledProtocols: ProtocolFlagsEnum, - rsyslogServer: z.string(), + rsyslogServer: z.string().max(33), }); export type NetworkValidation = z.infer; diff --git a/src/validation/config/position.ts b/src/validation/config/position.ts index 84e67603..1174d410 100644 --- a/src/validation/config/position.ts +++ b/src/validation/config/position.ts @@ -1,44 +1,22 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"]; - -export class PositionValidation implements - Omit< - Protobuf.Config.Config_PositionConfig, - keyof Message | (typeof DeprecatedPositionValidationFields)[number] - > { - @IsInt() - positionBroadcastSecs: number; - - @IsBoolean() - positionBroadcastSmartEnabled: boolean; - - @IsBoolean() - fixedPosition: boolean; - - @IsInt() - gpsUpdateInterval: number; - - @IsInt() - positionFlags: number; - - @IsInt() - rxGpio: number; - - @IsInt() - txGpio: number; - - @IsInt() - broadcastSmartMinimumDistance: number; - - @IsInt() - broadcastSmartMinimumIntervalSecs: number; - - @IsInt() - gpsEnGpio: number; - - @IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode) - gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode; -} +const GpsModeEnum = z.enum( + Protobuf.Config.Config_PositionConfig_GpsMode, +); + +export const PositionValidationSchema = z.object({ + positionBroadcastSecs: z.coerce.number().int().min(0), + positionBroadcastSmartEnabled: z.boolean(), + fixedPosition: z.boolean(), + gpsUpdateInterval: z.coerce.number().int().min(0), + positionFlags: z.coerce.number().int().min(0), + rxGpio: z.coerce.number().int().min(0), + txGpio: z.coerce.number().int().min(0), + broadcastSmartMinimumDistance: z.coerce.number().int().min(0), + broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0), + gpsEnGpio: z.coerce.number().int().min(0), + gpsMode: GpsModeEnum, +}); + +export type PositionValidation = z.infer; diff --git a/src/validation/config/power.ts b/src/validation/config/power.ts index a83f5f15..4c99fd76 100644 --- a/src/validation/config/power.ts +++ b/src/validation/config/power.ts @@ -1,35 +1,14 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator"; - -export class PowerValidation implements - Omit< - Protobuf.Config.Config_PowerConfig, - keyof Message | "powermonEnables" - > { - @IsBoolean() - isPowerSaving: boolean; - - @IsInt() - onBatteryShutdownAfterSecs: number; - - @IsNumber() - @Min(2) - @Max(4) - adcMultiplierOverride: number; - - @IsInt() - waitBluetoothSecs: number; - - @IsInt() - sdsSecs: number; - - @IsInt() - lsSecs: number; - - @IsInt() - minWakeSecs: number; - - @IsInt() - deviceBatteryInaAddress: number; -} +import { z } from "zod/v4"; + +export const PowerValidationSchema = z.object({ + isPowerSaving: z.boolean(), + onBatteryShutdownAfterSecs: z.coerce.number().int().min(0), + adcMultiplierOverride: z.coerce.number().min(0).max(4), + waitBluetoothSecs: z.coerce.number().int().min(0), + sdsSecs: z.coerce.number().int().min(0), + lsSecs: z.coerce.number().int().min(0), + minWakeSecs: z.coerce.number().int().min(0), + deviceBatteryInaAddress: z.coerce.number().int().min(0), +}); + +export type PowerValidation = z.infer; diff --git a/src/validation/config/security.test.ts b/src/validation/config/security.test.ts new file mode 100644 index 00000000..653baab4 --- /dev/null +++ b/src/validation/config/security.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { fromByteArray } from "base64-js"; +import { ParsedSecuritySchema, RawSecuritySchema } from "./security.ts"; + +function makeBase64OfLength(len: number): string { + return fromByteArray(new Uint8Array(len)); +} + +describe("RawSecuritySchema", () => { + const validKey = makeBase64OfLength(32); + + it("accepts valid security config", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: false, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [validKey, "", ""], + }); + expect(result.success).toBe(true); + }); + + it("rejects if privateKey is invalid", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: false, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: "badkey", + publicKey: validKey, + adminKey: [validKey, "", ""], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.includes("privateKey"))) + .toBe(true); + } + }); + + it("requires at least one adminKey if isManaged", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: true, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: ["", "", ""], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message === "formValidation.adminKeyRequiredWhenManaged" + ), + ).toBe(true); + } + }); + + it("accepts if at least one adminKey is valid when isManaged", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: true, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [validKey, "", ""], + }); + expect(result.success).toBe(true); + }); +}); + +describe("ParsedSecuritySchema", () => { + const validKey = new Uint8Array(32); + + it("accepts valid parsed security config", () => { + const result = ParsedSecuritySchema.safeParse({ + isManaged: false, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [validKey, new Uint8Array(), new Uint8Array()], + }); + console.log(result); + + expect(result.success).toBe(true); + }); + + it("requires at least one adminKey if isManaged", () => { + const result = ParsedSecuritySchema.safeParse({ + isManaged: true, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [new Uint8Array(), new Uint8Array(), new Uint8Array()], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message === "formValidation.adminKeyRequiredWhenManaged" + ), + ).toBe(true); + } + }); +}); diff --git a/src/validation/config/security.ts b/src/validation/config/security.ts index 5d6ed059..2053b085 100644 --- a/src/validation/config/security.ts +++ b/src/validation/config/security.ts @@ -1,36 +1,47 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsString } from "class-validator"; - -export class SecurityValidation implements - Omit< - Protobuf.Config.Config_SecurityConfig, - | keyof Message - | "adminKey" - | "privateKey" - | "publicKey" - > { - @IsBoolean() - adminChannelEnabled: boolean; - - @IsString() - adminKey: [string, string, string]; - - @IsBoolean() - bluetoothLoggingEnabled: boolean; - - @IsBoolean() - debugLogApiEnabled: boolean; - - @IsBoolean() - isManaged: boolean; - - @IsString() - privateKey: string; +import { z, ZodType } from "zod/v4"; +import { makePskHelpers } from "./../pskSchema.ts"; + +const { + stringSchema, + bytesSchema, + isValidKey, +} = makePskHelpers([32]); // 256-bit + +const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged"; + +function makeSecuritySchema( + keyMaker: (optional: boolean) => ZodType, +) { + return z + .object({ + isManaged: z.boolean(), + adminChannelEnabled: z.boolean(), + debugLogApiEnabled: z.boolean(), + serialEnabled: z.boolean(), + + privateKey: keyMaker(false), + publicKey: keyMaker(false), + adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]), + }) + .check((ctx) => { + if (ctx.value.isManaged) { + const hasAdmin = ctx.value.adminKey.some(isValidKey); + if (!hasAdmin) { + for (const path of [["isManaged"], ["adminKey", 0]] as const) { + ctx.issues.push({ + code: "custom", + message: isManagedRequiredMsg, + path: [...path], + input: ctx.value, + }); + } + } + } + }); +} - @IsString() - publicKey: string; +export const RawSecuritySchema = makeSecuritySchema(stringSchema); +export type RawSecurity = z.infer; - @IsBoolean() - serialEnabled: boolean; -} +export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema); +export type ParsedSecurity = z.infer; diff --git a/src/validation/moduleConfig/ambientLighting.ts b/src/validation/moduleConfig/ambientLighting.ts index 93124502..618b7044 100644 --- a/src/validation/moduleConfig/ambientLighting.ts +++ b/src/validation/moduleConfig/ambientLighting.ts @@ -1,24 +1,13 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class AmbientLightingValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig, - keyof Message - > { - @IsBoolean() - ledState: boolean; - - @IsInt() - current: number; - - @IsInt() - red: number; - - @IsInt() - green: number; - - @IsInt() - blue: number; -} +import { z } from "zod/v4"; + +export const AmbientLightingValidationSchema = z.object({ + ledState: z.boolean(), + current: z.coerce.number().int().min(0), + red: z.coerce.number().int().min(0).max(255), + green: z.coerce.number().int().min(0).max(255), + blue: z.coerce.number().int().min(0).max(255), +}); + +export type AmbientLightingValidation = z.infer< + typeof AmbientLightingValidationSchema +>; diff --git a/src/validation/moduleConfig/audio.ts b/src/validation/moduleConfig/audio.ts index b70e3dae..87c5e881 100644 --- a/src/validation/moduleConfig/audio.ts +++ b/src/validation/moduleConfig/audio.ts @@ -1,28 +1,18 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class AudioValidation - implements - Omit { - @IsBoolean() - codec2Enabled: boolean; +const Audio_BaudEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud, +); - @IsInt() - pttPin: number; +export const AudioValidationSchema = z.object({ + codec2Enabled: z.boolean(), + pttPin: z.coerce.number().int().min(0), + bitrate: Audio_BaudEnum, + i2sWs: z.coerce.number().int().min(0), + i2sSd: z.coerce.number().int().min(0), + i2sDin: z.coerce.number().int().min(0), + i2sSck: z.coerce.number().int().min(0), +}); - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud) - bitrate: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud; - - @IsInt() - i2sWs: number; - - @IsInt() - i2sSd: number; - - @IsInt() - i2sDin: number; - - @IsInt() - i2sSck: number; -} +export type AudioValidation = z.infer; diff --git a/src/validation/moduleConfig/cannedMessage.ts b/src/validation/moduleConfig/cannedMessage.ts index 2b70eaf5..0fd7d98e 100644 --- a/src/validation/moduleConfig/cannedMessage.ts +++ b/src/validation/moduleConfig/cannedMessage.ts @@ -1,45 +1,24 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt, Length } from "class-validator"; -export class CannedMessageValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig, - keyof Message - > { - @IsBoolean() - rotary1Enabled: boolean; - - @IsInt() - inputbrokerPinA: number; - - @IsInt() - inputbrokerPinB: number; - - @IsInt() - inputbrokerPinPress: number; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) - inputbrokerEventCw: - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) - inputbrokerEventCcw: - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) - inputbrokerEventPress: - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; - - @IsBoolean() - updown1Enabled: boolean; - - @IsBoolean() - enabled: boolean; - - @Length(2, 30) - allowInputSource: string; - - @IsBoolean() - sendBell: boolean; -} +const InputEventCharEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar, +); + +export const CannedMessageValidationSchema = z.object({ + rotary1Enabled: z.boolean(), + inputbrokerPinA: z.coerce.number().int().min(0), + inputbrokerPinB: z.coerce.number().int().min(0), + inputbrokerPinPress: z.coerce.number().int().min(0), + inputbrokerEventCw: InputEventCharEnum, + inputbrokerEventCcw: InputEventCharEnum, + inputbrokerEventPress: InputEventCharEnum, + updown1Enabled: z.boolean(), + enabled: z.boolean(), + allowInputSource: z.string().max(30), + sendBell: z.boolean(), +}); + +export type CannedMessageValidation = z.infer< + typeof CannedMessageValidationSchema +>; diff --git a/src/validation/moduleConfig/detectionSensor.ts b/src/validation/moduleConfig/detectionSensor.ts index b812df17..39f1f1e3 100644 --- a/src/validation/moduleConfig/detectionSensor.ts +++ b/src/validation/moduleConfig/detectionSensor.ts @@ -1,33 +1,21 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt, Length } from "class-validator"; - -export class DetectionSensorValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig, - keyof Message - > { - @IsBoolean() - enabled: boolean; - - @IsInt() - minimumBroadcastSecs: number; - - @IsInt() - stateBroadcastSecs: number; - - @IsBoolean() - sendBell: boolean; - - @Length(0, 20) - name: string; - - @IsInt() - monitorPin: number; - - @IsBoolean() - detectionTriggeredHigh: boolean; - - @IsBoolean() - usePullup: boolean; -} +import { z } from "zod/v4"; +import { Protobuf } from "@meshtastic/core"; + +const detectionTriggerTypeEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType, +); + +export const DetectionSensorValidationSchema = z.object({ + enabled: z.boolean(), + minimumBroadcastSecs: z.coerce.number().int().min(0), + stateBroadcastSecs: z.coerce.number().int().min(0), + sendBell: z.boolean(), + name: z.string().min(0).max(20), + monitorPin: z.coerce.number().int().min(0), + detectionTriggerType: detectionTriggerTypeEnum, + usePullup: z.boolean(), +}); + +export type DetectionSensorValidation = z.infer< + typeof DetectionSensorValidationSchema +>; diff --git a/src/validation/moduleConfig/externalNotification.ts b/src/validation/moduleConfig/externalNotification.ts index 5b68ffa3..b8769f0e 100644 --- a/src/validation/moduleConfig/externalNotification.ts +++ b/src/validation/moduleConfig/externalNotification.ts @@ -1,54 +1,23 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class ExternalNotificationValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig, - keyof Message - > { - @IsBoolean() - enabled: boolean; - - @IsInt() - outputMs: number; - - @IsInt() - output: number; - - @IsInt() - outputVibra: number; - - @IsInt() - outputBuzzer: number; - - @IsBoolean() - active: boolean; - - @IsBoolean() - alertMessage: boolean; - - @IsBoolean() - alertMessageVibra: boolean; - - @IsBoolean() - alertMessageBuzzer: boolean; - - @IsBoolean() - alertBell: boolean; - - @IsBoolean() - alertBellVibra: boolean; - - @IsBoolean() - alertBellBuzzer: boolean; - - @IsBoolean() - usePwm: boolean; - - @IsInt() - nagTimeout: number; - - @IsBoolean() - useI2sAsBuzzer: boolean; -} +import { z } from "zod/v4"; + +export const ExternalNotificationValidationSchema = z.object({ + enabled: z.boolean(), + outputMs: z.coerce.number().int().min(0), + output: z.coerce.number().int().min(0), + outputVibra: z.coerce.number().int().min(0), + outputBuzzer: z.coerce.number().int().min(0), + active: z.boolean(), + alertMessage: z.boolean(), + alertMessageVibra: z.boolean(), + alertMessageBuzzer: z.boolean(), + alertBell: z.boolean(), + alertBellVibra: z.boolean(), + alertBellBuzzer: z.boolean(), + usePwm: z.boolean(), + nagTimeout: z.coerce.number().int().min(0), + useI2sAsBuzzer: z.boolean(), +}); + +export type ExternalNotificationValidation = z.infer< + typeof ExternalNotificationValidationSchema +>; diff --git a/src/validation/moduleConfig/mqtt.ts b/src/validation/moduleConfig/mqtt.ts index 44312c8e..638f076f 100644 --- a/src/validation/moduleConfig/mqtt.ts +++ b/src/validation/moduleConfig/mqtt.ts @@ -1,59 +1,22 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { - IsBoolean, - IsNumber, - IsOptional, - IsString, - Length, -} from "class-validator"; - -export class MqttValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_MQTTConfig, - keyof Message | "mapReportSettings" - > { - @IsBoolean() - enabled: boolean; - - @Length(0, 30) - address: string; - - @Length(0, 30) - username: string; - - @Length(0, 30) - password: string; - - @IsBoolean() - encryptionEnabled: boolean; - - @IsBoolean() - jsonEnabled: boolean; - - @IsBoolean() - tlsEnabled: boolean; - - @IsString() - root: string; - - @IsBoolean() - proxyToClientEnabled: boolean; - - @IsBoolean() - mapReportingEnabled: boolean; - - mapReportSettings: MqttValidationMapReportSettings; -} - -export class MqttValidationMapReportSettings - implements - Omit { - @IsNumber() - @IsOptional() - publishIntervalSecs: number; - - @IsNumber() - @IsOptional() - positionPrecision: number; -} +import { z } from "zod/v4"; + +export const MqttValidationMapReportSettingsSchema = z.object({ + publishIntervalSecs: z.number().optional(), + positionPrecision: z.number().optional(), +}); + +export const MqttValidationSchema = z.object({ + enabled: z.boolean(), + address: z.string().min(0).max(30), + username: z.string().min(0).max(30), + password: z.string().min(0).max(30), + encryptionEnabled: z.boolean(), + jsonEnabled: z.boolean(), + tlsEnabled: z.boolean(), + root: z.string(), + proxyToClientEnabled: z.boolean(), + mapReportingEnabled: z.boolean(), + mapReportSettings: MqttValidationMapReportSettingsSchema, +}); + +export type MqttValidation = z.infer; diff --git a/src/validation/moduleConfig/neighborInfo.ts b/src/validation/moduleConfig/neighborInfo.ts index 513b8d7c..bd26f41b 100644 --- a/src/validation/moduleConfig/neighborInfo.ts +++ b/src/validation/moduleConfig/neighborInfo.ts @@ -1,13 +1,10 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; +import { z } from "zod/v4"; -export class NeighborInfoValidation - implements - Omit { - @IsBoolean() - enabled: boolean; +export const NeighborInfoValidationSchema = z.object({ + enabled: z.boolean(), + updateInterval: z.coerce.number().int().min(0), +}); - @IsInt() - updateInterval: number; -} +export type NeighborInfoValidation = z.infer< + typeof NeighborInfoValidationSchema +>; diff --git a/src/validation/moduleConfig/paxcounter.ts b/src/validation/moduleConfig/paxcounter.ts index 6b3eeb9b..61359065 100644 --- a/src/validation/moduleConfig/paxcounter.ts +++ b/src/validation/moduleConfig/paxcounter.ts @@ -1,19 +1,10 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; +import { z } from "zod/v4"; -export class PaxcounterValidation - implements - Omit { - @IsBoolean() - enabled: boolean; +export const PaxcounterValidationSchema = z.object({ + enabled: z.boolean(), + paxcounterUpdateInterval: z.coerce.number().int().min(0), + bleThreshold: z.coerce.number().int(), + wifiThreshold: z.coerce.number().int(), +}); - @IsInt() - paxcounterUpdateInterval: number; - - @IsInt() - bleThreshold: number; - - @IsInt() - wifiThreshold: number; -} +export type PaxcounterValidation = z.infer; diff --git a/src/validation/moduleConfig/rangeTest.ts b/src/validation/moduleConfig/rangeTest.ts index 5debf256..1dbf54c3 100644 --- a/src/validation/moduleConfig/rangeTest.ts +++ b/src/validation/moduleConfig/rangeTest.ts @@ -1,16 +1,9 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; +import { z } from "zod/v4"; -export class RangeTestValidation - implements - Omit { - @IsBoolean() - enabled: boolean; +export const RangeTestValidationSchema = z.object({ + enabled: z.boolean(), + sender: z.coerce.number().int().min(0), + save: z.boolean(), +}); - @IsInt() - sender: number; - - @IsBoolean() - save: boolean; -} +export type RangeTestValidation = z.infer; diff --git a/src/validation/moduleConfig/serial.ts b/src/validation/moduleConfig/serial.ts index c814a71b..95966105 100644 --- a/src/validation/moduleConfig/serial.ts +++ b/src/validation/moduleConfig/serial.ts @@ -1,31 +1,22 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class SerialValidation - implements - Omit { - @IsBoolean() - enabled: boolean; - - @IsBoolean() - echo: boolean; - - @IsInt() - rxd: number; - - @IsInt() - txd: number; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud) - baud: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud; - - @IsInt() - timeout: number; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode) - mode: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode; - - @IsBoolean() - overrideConsoleSerialPort: boolean; -} +const Serial_BaudEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud, +); +const Serial_ModeEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode, +); + +export const SerialValidationSchema = z.object({ + enabled: z.boolean(), + echo: z.boolean(), + rxd: z.coerce.number().int().min(0), + txd: z.coerce.number().int().min(0), + baud: Serial_BaudEnum, + timeout: z.coerce.number().int().min(0), + mode: Serial_ModeEnum, + overrideConsoleSerialPort: z.boolean(), +}); + +export type SerialValidation = z.infer; diff --git a/src/validation/moduleConfig/storeForward.ts b/src/validation/moduleConfig/storeForward.ts index e539dd4b..6c2257d2 100644 --- a/src/validation/moduleConfig/storeForward.ts +++ b/src/validation/moduleConfig/storeForward.ts @@ -1,24 +1,13 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class StoreForwardValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, - keyof Message | "isServer" - > { - @IsBoolean() - enabled: boolean; - - @IsBoolean() - heartbeat: boolean; - - @IsInt() - records: number; - - @IsInt() - historyReturnMax: number; - - @IsInt() - historyReturnWindow: number; -} +import { z } from "zod/v4"; + +export const StoreForwardValidationSchema = z.object({ + enabled: z.boolean(), + heartbeat: z.boolean(), + records: z.coerce.number().int().min(0), + historyReturnMax: z.coerce.number().int().min(0), + historyReturnWindow: z.coerce.number().int().min(0), +}); + +export type StoreForwardValidation = z.infer< + typeof StoreForwardValidationSchema +>; diff --git a/src/validation/moduleConfig/telemetry.ts b/src/validation/moduleConfig/telemetry.ts index 921fb04a..8f6605a2 100644 --- a/src/validation/moduleConfig/telemetry.ts +++ b/src/validation/moduleConfig/telemetry.ts @@ -1,37 +1,18 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class TelemetryValidation - implements - Omit { - @IsInt() - deviceUpdateInterval: number; - - @IsInt() - environmentUpdateInterval: number; - - @IsBoolean() - environmentMeasurementEnabled: boolean; - - @IsBoolean() - environmentScreenEnabled: boolean; - - @IsBoolean() - environmentDisplayFahrenheit: boolean; - - @IsBoolean() - airQualityEnabled: boolean; - - @IsInt() - airQualityInterval: number; - - @IsBoolean() - powerMeasurementEnabled: boolean; - - @IsInt() - powerUpdateInterval: number; - - @IsBoolean() - powerScreenEnabled: boolean; -} +import { z } from "zod/v4"; + +export const TelemetryValidationSchema = z.object({ + deviceUpdateInterval: z.coerce.number().int().min(0), + environmentUpdateInterval: z.coerce.number().int().min(0), + environmentMeasurementEnabled: z.boolean(), + environmentScreenEnabled: z.boolean(), + environmentDisplayFahrenheit: z.boolean(), + airQualityEnabled: z.boolean(), + airQualityInterval: z.coerce.number().int().min(0), + powerMeasurementEnabled: z.boolean(), + powerUpdateInterval: z.coerce.number().int().min(0), + powerScreenEnabled: z.boolean(), +}); + +export type TelemetryValidation = z.infer< + typeof TelemetryValidationSchema +>; diff --git a/src/validation/pskSchema.test.ts b/src/validation/pskSchema.test.ts new file mode 100644 index 00000000..20ec58a8 --- /dev/null +++ b/src/validation/pskSchema.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { makePskHelpers } from "./pskSchema.ts"; +import { fromByteArray } from "base64-js"; + +function makeBase64OfLength(len: number): string { + return fromByteArray(new Uint8Array(len)); +} + +describe("stringSchema", () => { + it("accepts valid base64 string of allowed length", () => { + const { stringSchema } = makePskHelpers([16]); + const valid = makeBase64OfLength(16); + expect(() => stringSchema().parse(valid)).not.toThrow(); + }); + + it("rejects base64 string of disallowed length", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const invalid = makeBase64OfLength(8); + const result = stringSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + + it("rejects invalid base64 string", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const result = stringSchema().safeParse("not_base64!"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.format); + } + }); + + it("rejects empty string if not optional and 0 not allowed", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const result = stringSchema().safeParse(""); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.required); + } + }); + + it("accepts empty string if 0 is allowed", () => { + const { stringSchema } = makePskHelpers([0]); + const result = stringSchema().safeParse(""); + expect(result.success).toBe(true); + }); + + it("accepts empty string if optional=true", () => { + const { stringSchema } = makePskHelpers([16]); + const result = stringSchema(true).safeParse(""); + expect(result.success).toBe(true); + }); + + it("accepts all allowed lengths", () => { + const { stringSchema } = makePskHelpers([8, 16, 32]); + for (const len of [8, 16, 32]) { + const valid = makeBase64OfLength(len); + const result = stringSchema().safeParse(valid); + expect(result.success).toBe(true); + } + }); + + it("accepts valid base64 string as optional when optional=true", () => { + const { stringSchema } = makePskHelpers([16]); + const valid = makeBase64OfLength(16); + const result = stringSchema(true).safeParse(valid); + expect(result.success).toBe(true); + }); + + it("rejects base64 string with correct length but extra padding", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const valid = makeBase64OfLength(16) + "=="; + const result = stringSchema().safeParse(valid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.format); + } + }); + + it("accepts empty string if allowedByteLengths includes 0 and optional=false", () => { + const { stringSchema } = makePskHelpers([0, 16]); + const result = stringSchema(false).safeParse(""); + expect(result.success).toBe(true); + }); + + it("rejects base64 string with valid format but not in allowedByteLengths", () => { + const { stringSchema, msgs } = makePskHelpers([8, 32]); + const invalid = makeBase64OfLength(16); + const result = stringSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + + describe("bytesSchema", () => { + it("accepts valid byte array of allowed length", () => { + const { bytesSchema } = makePskHelpers([16]); + const valid = new Uint8Array(16); + expect(() => bytesSchema().parse(valid)).not.toThrow(); + }); + + it("rejects byte array of disallowed length", () => { + const { bytesSchema, msgs } = makePskHelpers([16]); + const invalid = new Uint8Array(8); + const result = bytesSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + + it("rejects non-Uint8Array input", () => { + const { bytesSchema } = makePskHelpers([16]); + const result = bytesSchema().safeParse([1, 2, 3]); + expect(result.success).toBe(false); + }); + + it("rejects empty array if not optional and 0 not allowed", () => { + const { bytesSchema, msgs } = makePskHelpers([16]); + const result = bytesSchema().safeParse(new Uint8Array(0)); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.required); + } + }); + + it("accepts empty array if 0 is allowed", () => { + const { bytesSchema } = makePskHelpers([0]); + const result = bytesSchema().safeParse(new Uint8Array(0)); + expect(result.success).toBe(true); + }); + + it("accepts empty array if optional=true", () => { + const { bytesSchema } = makePskHelpers([16]); + const result = bytesSchema(true).safeParse(new Uint8Array(0)); + expect(result.success).toBe(true); + }); + + it("accepts all allowed lengths", () => { + const { bytesSchema } = makePskHelpers([8, 16, 32]); + for (const len of [8, 16, 32]) { + const valid = new Uint8Array(len); + const result = bytesSchema().safeParse(valid); + expect(result.success).toBe(true); + } + }); + + it("accepts valid byte array as optional when optional=true", () => { + const { bytesSchema } = makePskHelpers([16]); + const valid = new Uint8Array(16); + const result = bytesSchema(true).safeParse(valid); + expect(result.success).toBe(true); + }); + + it("accepts empty array if allowedByteLengths includes 0 and optional=false", () => { + const { bytesSchema } = makePskHelpers([0, 16]); + const result = bytesSchema(false).safeParse(new Uint8Array(0)); + expect(result.success).toBe(true); + }); + + it("rejects byte array with valid format but not in allowedByteLengths", () => { + const { bytesSchema, msgs } = makePskHelpers([8, 32]); + const invalid = new Uint8Array(16); + const result = bytesSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + }); +}); diff --git a/src/validation/pskSchema.ts b/src/validation/pskSchema.ts new file mode 100644 index 00000000..ee09b56d --- /dev/null +++ b/src/validation/pskSchema.ts @@ -0,0 +1,70 @@ +import { z, ZodType } from "zod/v4"; +import { toByteArray } from "base64-js"; + +export function makePskHelpers( + allowedByteLengths: readonly number[], +) { + const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | "); + const msgs = { + format: "formValidation.invalidFormat.key", + required: "formValidation.required.key", + length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`, + } as const; + + function tryParse(str: string): Uint8Array | null { + try { + return toByteArray(str); + } catch { + return null; + } + } + + function isValidString(str: string): boolean { + const arr = tryParse(str); + return arr !== null && + allowedByteLengths.includes(arr.byteLength); + } + + function isValidKey(v: unknown): boolean { + if (typeof v === "string") return isValidString(v); + if (v instanceof Uint8Array) { + return allowedByteLengths.includes(v.byteLength); + } + return false; + } + + const stringSchema = (optional = false) => + z.string() + .refine((s) => + optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), { + message: msgs.required, + }) + .refine((s) => + s === "" || tryParse(s) !== null, { message: msgs.format }) + .refine((s) => + s === "" || isValidString(s), { + message: msgs.length, + params: { bits: bitsLabel }, + }); + + const bytesSchema = (optional = false): ZodType => + z.instanceof(Uint8Array) + .refine( + (arr) => + optional || arr.byteLength !== 0 || allowedByteLengths.includes(0), + { message: msgs.required }, + ) + .refine( + (arr) => optional || allowedByteLengths.includes(arr.byteLength), + { message: msgs.length, params: { bits: bitsLabel } }, + ); + + return { + allowedByteLengths, + msgs, + tryParseStringKey: tryParse, + isValidKey, + stringSchema, + bytesSchema, + }; +} diff --git a/src/validation/rasterSource.ts b/src/validation/rasterSource.ts deleted file mode 100644 index 965206d4..00000000 --- a/src/validation/rasterSource.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsArray, IsBoolean, IsNumber, IsString, IsUrl } from "class-validator"; - -import type { RasterSource } from "@core/stores/appStore.ts"; - -export class MapValidation { - @IsArray() - rasterSources: MapValidation_RasterSources[]; -} - -export class MapValidation_RasterSources implements RasterSource { - @IsBoolean() - enabled: boolean; - - @IsString() - title: string; - - @IsUrl() - tiles: string; - - @IsNumber() - tileSize: number; -} diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 7130add5..f147e0c6 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -1,7 +1,7 @@ -import { ZodError, ZodSchema } from "zod"; +import { ZodError, ZodType } from "zod/v4"; export function validateSchema( - schema: ZodSchema, + schema: ZodType, data: unknown, ): { success: true; data: T } | { success: false; errors: ZodError["issues"] } { const result = schema.safeParse(data); diff --git a/vitest.config.ts b/vitest.config.ts index 24aa0ac4..537096e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,6 @@ export default defineConfig({ restoreMocks: true, root: path.resolve(process.cwd(), "./src"), include: ["**/*.{test,spec}.{ts,tsx}"], - setupFiles: ["./src/tests/setupTests.ts"], + setupFiles: ["./src/tests/setupTests.ts", "./src/core/utils/test.tsx"], }, });