Browse Source

Merge branch 'meshtastic:main' into remove-meshtastic-js

pull/638/head
Jeremy Gallant 1 year ago
committed by GitHub
parent
commit
4ff567e43c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      deno.json
  2. 217
      deno.lock
  3. 7
      package.json
  4. 11
      src/App.tsx
  5. 27
      src/PageRouter.tsx
  6. 14
      src/components/CommandPalette/index.tsx
  7. 6
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx
  8. 19
      src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx
  9. 42
      src/components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.test.tsx
  10. 22
      src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx
  11. 304
      src/components/Form/DynamicForm.test.tsx
  12. 148
      src/components/Form/DynamicForm.tsx
  13. 22
      src/components/Form/FormPasswordGenerator.tsx
  14. 64
      src/components/Form/createZodResolver.ts
  15. 65
      src/components/PageComponents/Channel.tsx
  16. 79
      src/components/PageComponents/Config/Bluetooth.tsx
  17. 7
      src/components/PageComponents/Config/Device/index.tsx
  18. 9
      src/components/PageComponents/Config/Display.tsx
  19. 7
      src/components/PageComponents/Config/LoRa.tsx
  20. 10
      src/components/PageComponents/Config/Network/index.tsx
  21. 7
      src/components/PageComponents/Config/Position.tsx
  22. 7
      src/components/PageComponents/Config/Power.tsx
  23. 402
      src/components/PageComponents/Config/Security/Security.tsx
  24. 30
      src/components/PageComponents/Config/Security/securityReducer.tsx
  25. 35
      src/components/PageComponents/Config/Security/types.ts
  26. 13
      src/components/PageComponents/Map/NodeDetail.tsx
  27. 2
      src/components/PageComponents/Messages/MessageItem.tsx
  28. 7
      src/components/PageComponents/ModuleConfig/AmbientLighting.tsx
  29. 7
      src/components/PageComponents/ModuleConfig/Audio.tsx
  30. 7
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  31. 19
      src/components/PageComponents/ModuleConfig/DetectionSensor.tsx
  32. 7
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  33. 7
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  34. 7
      src/components/PageComponents/ModuleConfig/NeighborInfo.tsx
  35. 7
      src/components/PageComponents/ModuleConfig/Paxcounter.tsx
  36. 7
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  37. 7
      src/components/PageComponents/ModuleConfig/Serial.tsx
  38. 7
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  39. 7
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  40. 43
      src/components/Sidebar.tsx
  41. 69
      src/components/UI/ErrorPage.tsx
  42. 11
      src/components/UI/Generator.tsx
  43. 16
      src/components/UI/Typography/Link.tsx
  44. 13
      src/core/stores/deviceStore.ts
  45. 12
      src/core/stores/messageStore/index.ts
  46. 7
      src/core/stores/messageStore/messageStore.test.ts
  47. 66
      src/core/utils/dotPath.test.ts
  48. 19
      src/core/utils/dotPath.ts
  49. 82
      src/core/utils/test.tsx
  50. 31
      src/i18n/locales/en/common.json
  51. 14
      src/i18n/locales/en/deviceConfig.json
  52. 4
      src/i18n/locales/en/messages.json
  53. 6
      src/i18n/locales/en/moduleConfig.json
  54. 18
      src/i18n/locales/en/ui.json
  55. 18
      src/index.tsx
  56. 72
      src/pages/Messages.tsx
  57. 59
      src/routeTree.gen.ts
  58. 79
      src/routes.tsx
  59. 32
      src/tests/setupTests.ts
  60. 141
      src/validation/channel.test.ts
  61. 85
      src/validation/channel.ts
  62. 24
      src/validation/config/bluetooth.ts
  63. 63
      src/validation/config/device.ts
  64. 70
      src/validation/config/display.ts
  65. 92
      src/validation/config/lora.ts
  66. 24
      src/validation/config/network.ts
  67. 62
      src/validation/config/position.ts
  68. 49
      src/validation/config/power.ts
  69. 113
      src/validation/config/security.test.ts
  70. 79
      src/validation/config/security.ts
  71. 37
      src/validation/moduleConfig/ambientLighting.ts
  72. 38
      src/validation/moduleConfig/audio.ts
  73. 65
      src/validation/moduleConfig/cannedMessage.ts
  74. 54
      src/validation/moduleConfig/detectionSensor.ts
  75. 77
      src/validation/moduleConfig/externalNotification.ts
  76. 81
      src/validation/moduleConfig/mqtt.ts
  77. 19
      src/validation/moduleConfig/neighborInfo.ts
  78. 25
      src/validation/moduleConfig/paxcounter.ts
  79. 21
      src/validation/moduleConfig/rangeTest.ts
  80. 49
      src/validation/moduleConfig/serial.ts
  81. 37
      src/validation/moduleConfig/storeForward.ts
  82. 55
      src/validation/moduleConfig/telemetry.ts
  83. 174
      src/validation/pskSchema.test.ts
  84. 70
      src/validation/pskSchema.ts
  85. 22
      src/validation/rasterSource.ts
  86. 4
      src/validation/validate.ts
  87. 2
      vitest.config.ts

2
deno.json

@ -31,12 +31,14 @@
},
"fmt": {
"exclude": [
"src/*.gen.ts",
"*.test.ts",
"*.test.tsx"
]
},
"lint": {
"exclude": [
"src/*.gen.ts",
"*.test.ts",
"*.test.tsx"
],

217
deno.lock

@ -27,6 +27,10 @@
"npm:@radix-ui/react-toggle-group@^1.1.9": "1.1.10_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]",
"npm:@radix-ui/react-tooltip@^1.2.4": "1.2.4_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]",
"npm:@tailwindcss/postcss@^4.1.5": "4.1.5",
"npm:@tanstack/react-router-devtools@^1.120.16": "1.120.16_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]",
"npm:@tanstack/react-router@^1.120.15": "[email protected][email protected][email protected]",
"npm:@tanstack/router-devtools@^1.120.15": "1.120.15_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]",
"npm:@tanstack/router-plugin@^1.120.15": "1.120.15_@[email protected][email protected][email protected][email protected][email protected]__@[email protected][email protected]_@[email protected][email protected][email protected][email protected]_@[email protected]",
"npm:@testing-library/jest-dom@^6.6.3": "6.6.3",
"npm:@testing-library/react@^16.3.0": "16.3.0_@[email protected]_@[email protected]_@[email protected]__@[email protected][email protected][email protected][email protected]",
"npm:@testing-library/user-event@^14.6.1": "14.6.1_@[email protected]",
@ -366,6 +370,20 @@
"@babel/helper-plugin-utils"
]
},
"@babel/[email protected]_@[email protected]": {
"integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
"dependencies": [
"@babel/core",
"@babel/helper-plugin-utils"
]
},
"@babel/[email protected]_@[email protected]": {
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dependencies": [
"@babel/core",
"@babel/helper-plugin-utils"
]
},
"@babel/[email protected]_@[email protected]": {
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
"dependencies": [
@ -2341,6 +2359,136 @@
"tailwindcss"
]
},
"@tanstack/[email protected]": {
"integrity": "sha512-K7JJNrRVvyjAVnbXOH2XLRhFXDkeP54Kt2P4FR1Kl2KDGlIbkua5VqZQD2rot3qaDrpufyUa63nuLai1kOLTsQ=="
},
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]": {
"integrity": "sha512-5KcUXc3fkiLo/6Y56gOM3JqmYXG1ElIH2iyUWuG5IlcegLrpXhu4OBQ+8Q4+62CD0OKy0ifUDyemrCOAEOfCvw==",
"dependencies": [
"@tanstack/react-router",
"@tanstack/router-devtools-core",
"react",
"react-dom",
"solid-js"
]
},
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]": {
"integrity": "sha512-DWXmMLknVJJMGP2k5yeUWBDhJOHbV2jVfnZKxtGzA64xXhwDFgU9qpodcmYSq3+kHWsKrd7iX0wc7d27rGwGDA==",
"dependencies": [
"@tanstack/react-router",
"@tanstack/router-devtools-core",
"react",
"react-dom",
"solid-js"
]
},
"@tanstack/[email protected][email protected][email protected][email protected]": {
"integrity": "sha512-apzBmXh4pHwqUGU3kD8y2FJMi7rVoUbRxh5oV7v8kEb6Aq5Xpdo+OcpThw8h/M2zv7v4Ef8IoY6WFCKKu3HBjQ==",
"dependencies": [
"@tanstack/history",
"@tanstack/react-store",
"@tanstack/router-core",
"[email protected]",
"react",
"react-dom",
"tiny-invariant",
"tiny-warning"
]
},
"@tanstack/[email protected][email protected][email protected][email protected]": {
"integrity": "sha512-qUTEKdId6QPWGiWyKAPf/gkN29scEsz6EUSJ0C3HgLMgaqTAyBsQ2sMCfGVcqb+kkhEXAdjleCgH6LAPD6f2sA==",
"dependencies": [
"@tanstack/store",
"react",
"react-dom",
"use-sync-external-store"
]
},
"@tanstack/[email protected]": {
"integrity": "sha512-soLj+mEuvSxAVFK/3b85IowkkvmSuQL6J0RSIyKJFGFgy0CmUzpcBGEO99+JNWvvvzHgIoY4F4KtLIN+rvFSFA==",
"dependencies": [
"@tanstack/history",
"@tanstack/store",
"tiny-invariant"
]
},
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected]": {
"integrity": "sha512-AT9obPHKpJqnHMbwshozSy6sApg5LchiAll3blpS3MMDybUCidYHrdhe9MZJLmlC99IQiEGmuZERP3VRcuPNHg==",
"dependencies": [
"@tanstack/router-core",
"clsx",
"goober",
"solid-js",
"tiny-invariant"
]
},
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]": {
"integrity": "sha512-u7KbvupWSppoEUYuhCBzmWkd1hcODzHhvGIuWZKoQO9q/qeNY5XptbzGqBSUooXyoF4T/pAdCRILF5zFIqexJw==",
"dependencies": [
"@tanstack/react-router",
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]",
"clsx",
"goober",
"react",
"react-dom"
]
},
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected][email protected][email protected]": {
"integrity": "sha512-QwZ0rNXxzgOEUDRRAEWVjofKxuxSMIYEdYC3z20k6a7jkLC6pnlCORFx41Vf4xVCO6eElqlrUKXWLTleYSsvQw==",
"dependencies": [
"@tanstack/react-router",
"@tanstack/virtual-file-routes",
"prettier",
"tsx",
"zod"
],
"optionalPeers": [
"@tanstack/react-router"
]
},
"@tanstack/[email protected]_@[email protected][email protected][email protected][email protected][email protected]__@[email protected][email protected]_@[email protected][email protected][email protected][email protected]_@[email protected]": {
"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/[email protected]": {
"integrity": "sha512-Dng4y+uLR9b5zPGg7dHReHOTHQa6x+G6nCoZshsDtWrYsrdCcJEtLyhwZ5wG8OyYS6dVr/Cn+E5Bd2b6BhJ89w==",
"dependencies": [
"@babel/generator",
"@babel/parser",
"ansis",
"diff"
]
},
"@tanstack/[email protected]": {
"integrity": "sha512-PjUQKXEXhLYj2X5/6c1Xn/0/qKY0IVFxTJweopRfF26xfjVyb14yALydJrHupDh3/d+1WKmfEgZPBVCmDkzzwg=="
},
"@tanstack/[email protected]": {
"integrity": "sha512-XLUh1Py3AftcERrxkxC5Y5m5mfllRH3YR6YVlyjFgI2Tc2Ssy2NKmQFQIafoxfW459UJ8Dn81nWKETEIJifE4g=="
},
"@testing-library/[email protected]": {
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dependencies": [
@ -3972,6 +4120,9 @@
"[email protected]": {
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
},
"[email protected]": {
"integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="
},
"[email protected]": {
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dependencies": [
@ -4071,6 +4222,15 @@
"possible-typed-array-names"
]
},
"[email protected]": {
"integrity": "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==",
"dependencies": [
"@babel/core",
"@babel/parser",
"@babel/traverse",
"@babel/types"
]
},
"[email protected]_@[email protected]": {
"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
"dependencies": [
@ -4559,6 +4719,9 @@
"[email protected]": {
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
"[email protected]": {
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="
},
"[email protected]": {
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"dependencies": [
@ -5018,6 +5181,12 @@
"gopd"
]
},
"[email protected][email protected]": {
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"dependencies": [
"csstype"
]
},
"[email protected]": {
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
},
@ -5937,6 +6106,10 @@
"[email protected]": {
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw=="
},
"[email protected]": {
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"bin": true
},
"[email protected]": {
"integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="
},
@ -6358,6 +6531,15 @@
"randombytes"
]
},
"[email protected][email protected]": {
"integrity": "sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ==",
"dependencies": [
"seroval"
]
},
"[email protected]": {
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="
},
"[email protected]": {
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"dependencies": [
@ -6465,6 +6647,14 @@
"[email protected]": {
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig=="
},
"[email protected][email protected]": {
"integrity": "sha512-/saTKi8iWEM233n5OSi1YHCCuh66ZIQ7aK2hsToPe4tqGm7qAejU1SwNuTPivbWAYq7SjuHVVYxxuZQNRbICiw==",
"dependencies": [
"csstype",
"seroval",
"seroval-plugins"
]
},
"[email protected]": {
"integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA=="
},
@ -6720,6 +6910,12 @@
"setimmediate"
]
},
"[email protected]": {
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"[email protected]": {
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"[email protected]": {
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
},
@ -6909,6 +7105,14 @@
"[email protected]": {
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="
},
"[email protected]": {
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
"dependencies": [
"acorn",
"[email protected]",
"webpack-virtual-modules"
]
},
"[email protected]": {
"integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="
},
@ -6951,6 +7155,12 @@
"@types/react"
]
},
"[email protected][email protected]": {
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"dependencies": [
"react"
]
},
"[email protected]": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
@ -7100,6 +7310,9 @@
"[email protected]": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},
"[email protected]": {
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="
},
"[email protected]": {
"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",

7
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",

11
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 => {
}}
/>
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}>
<div
className="flex h-screen flex-col bg-background-primary text-text-primary"
@ -49,7 +50,7 @@ export const App = (): JSX.Element => {
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<PageRouter />
<Outlet />
</MapProvider>
</div>
)
@ -65,4 +66,4 @@ export const App = (): JSX.Element => {
</DeviceWrapper>
</ErrorBoundary>
);
};
}

27
src/PageRouter.tsx

@ -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 }) => (
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
);
export const PageRouter = () => {
const { activePage } = useDevice();
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
{activePage === "messages" && <MessagesPage />}
{activePage === "map" && <MapPage />}
{activePage === "config" && <ConfigPage />}
{activePage === "channels" && <ChannelsPage />}
{activePage === "nodes" && <NodesPage />}
</ErrorBoundary>
);
};

14
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" });
},
},
],

6
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,

19
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() {

42
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(
<DeviceWrapper device={mockDevice}>
{ui}
</DeviceWrapper>,
<RouterProvider router={testRouter}>
<DeviceWrapper device={mockDevice}>
{ui}
</DeviceWrapper>
</RouterProvider>,
);
};
it("renders the dialog when open is true", () => {
renderWithDeviceContext(
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
@ -37,7 +51,7 @@ describe("UnsafeRolesDialog", () => {
});
it("displays the correct links", () => {
renderWithDeviceContext(
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
@ -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(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
@ -75,7 +89,7 @@ describe("UnsafeRolesDialog", () => {
it("emits the correct event when closing via close button", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
@ -89,7 +103,7 @@ describe("UnsafeRolesDialog", () => {
it("emits the correct event when dismissing", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);
@ -103,7 +117,7 @@ describe("UnsafeRolesDialog", () => {
it("emits the correct event when confirming", () => {
const eventSpy = vi.spyOn(eventBus, "emit");
renderWithDeviceContext(
renderWithProviders(
<UnsafeRolesDialog open={true} onOpenChange={vi.fn()} />,
);

22
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(() => {

304
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(
<DynamicForm<z.infer<typeof schema>>
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(
<DynamicForm<z.infer<typeof schema>>
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(
<DynamicForm<z.infer<typeof schema>>
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(
<DynamicForm<z.infer<typeof schema>>
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(
<DynamicForm<{ name: string }>
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(
<DynamicForm<z.infer<typeof schema>>
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(
<DynamicForm<{ foo: string }>
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(
<DynamicForm<z.infer<typeof schema>>
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");
});
});
});

148
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<T> {
fieldName: Path<T>;
@ -51,6 +59,8 @@ export interface DynamicFormProps<T extends FieldValues> {
validationText?: string;
fields: FieldProps<T>[];
}[];
validationSchema?: ZodType<T>;
formId?: string;
}
export function DynamicForm<T extends FieldValues>({
@ -59,11 +69,45 @@ export function DynamicForm<T extends FieldValues>({
hasSubmitButton,
defaultValues,
fieldGroups,
validationSchema,
formId,
}: DynamicFormProps<T>) {
const { handleSubmit, control, getValues } = useForm<T>({
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<T>[],
@ -86,46 +130,66 @@ export function DynamicForm<T extends FieldValues>({
};
return (
<form
className="space-y-8"
{...(submitType === "onSubmit" ? { onSubmit: handleSubmit(onSubmit) } : {
onChange: handleSubmit(onSubmit),
})}
>
{fieldGroups.map((fieldGroup) => (
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
<div>
<Heading as="h4" className="font-medium">
{fieldGroup.label}
</Heading>
<Subtle>{fieldGroup.description}</Subtle>
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
</div>
<FormProvider {...methods}>
<form
className="space-y-8"
{...(submitType === "onSubmit"
? { onSubmit: handleSubmit(onSubmit) }
: { onChange: handleSubmit(onSubmit) })}
>
{fieldGroups.map((fieldGroup) => (
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
<div>
<Heading as="h4" className="font-medium">
{fieldGroup.label}
</Heading>
<Subtle>{fieldGroup.description}</Subtle>
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
</div>
{fieldGroup.fields.map((field) => {
return (
<FieldWrapper
key={field.label}
label={field.label}
fieldName={field.name}
description={field.description}
valid={field.validationText === undefined ||
field.validationText === ""}
validationText={field.validationText}
>
<DynamicFormField
field={field}
control={control}
disabled={isDisabled(field.disabledBy, field.disabled)}
/>
</FieldWrapper>
);
})}
</div>
))}
{hasSubmitButton && (
<Button type="submit" variant="outline">Submit</Button>
)}
</form>
{fieldGroup.fields.map((field) => {
const error = get(formState.errors, field.name as string);
return (
<FieldWrapper
key={field.label}
label={field.label}
fieldName={field.name}
description={field.description}
valid={validationSchema // keep backwards compat with not updated cfg pages
? !error
: field.validationText === undefined ||
field.validationText === ""}
validationText={validationSchema
? (error
? String(
t([`formValidation.${error.type}`, error.message], {
returnObjects: false,
...error.params,
}),
)
: "")
: field.validationText}
>
<DynamicFormField
field={field}
control={control}
disabled={isDisabled(field.disabledBy, field.disabled)}
/>
</FieldWrapper>
);
})}
</div>
))}
{hasSubmitButton && (
<Button
type="submit"
variant="outline"
disabled={!formState.isValid}
>
Submit
</Button>
)}
</form>
</FormProvider>
);
}

22
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<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
@ -14,8 +14,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
selectChange: (event: string) => void;
inputChange?: React.ChangeEventHandler<HTMLInputElement>;
selectChange?: (event: string) => void;
actionButtons: {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
@ -32,19 +32,27 @@ export function PasswordGenerator<T extends FieldValues>({
disabled,
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
const { isVisible } = usePasswordVisibilityToggle();
const { trigger } = useFormContext();
useEffect(() => {
trigger(field.name);
}, [field.devicePSKBitCount, field.name, trigger]);
return (
<Controller
name={field.name}
control={control}
render={({ field: { value, ...rest } }) => (
render={({ field: { value, onChange, ...rest } }) => (
<Generator
type={field.hide && !isVisible ? "password" : "text"}
id={field.id}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
selectChange={field.selectChange}
inputChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(e);
}}
selectChange={field.selectChange ?? (() => {})}
value={value}
variant={field.validationText ? "invalid" : "default"}
actionButtons={field.actionButtons}

64
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<T extends FieldValues>(
schema: ZodType<T, unknown>,
): Resolver<T, unknown> {
return (
values: T,
_context: unknown,
_options?: ResolverOptions<T>,
): ResolverResult<T> => {
const result = schema.safeParse(values);
if (result.success) {
return {
values: result.data,
errors: {},
};
}
const errors: Record<
string,
FieldError & { params?: Record<string, unknown> }
> = {};
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<string, unknown> } = {
type: newCode,
message: message,
...(Object.keys(params).length ? { params } : {}),
};
if (!errors[key]) {
errors[key] = fieldError;
}
}
return {
values: {} as T,
errors,
};
};
}

65
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<boolean>(
false,
);
const [pass, setPass] = useState<string>(
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
);
const [bitCount, setBits] = useState<number>(
const [byteCount, setBytes] = useState<number>(
channel?.settings?.psk.length ?? 16,
);
const [validationText, setValidationText] = useState<string>();
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
false,
const ChannelValidationSchema = useMemo(
() => {
return makeChannelSchema(byteCount);
},
[byteCount],
);
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
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<HTMLInputElement>) => {
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) => {
<DynamicForm<ChannelValidation>
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: [

79
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<HTMLInputElement>) => {
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 (
<DynamicForm<BluetoothValidation>
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,
},
},
],
},

7
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 (
<DynamicForm<DeviceValidation>
onSubmit={onSubmit}
validationSchema={DeviceValidationSchema}
formId="Config_DeviceConfig"
defaultValues={config.device}
fieldGroups={[
{

9
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 (
<DynamicForm<DisplayValidation>
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,
},
},
{

7
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 (
<DynamicForm<LoRaValidation>
onSubmit={onSubmit}
validationSchema={LoRaValidationSchema}
formId="Config_LoRaConfig"
defaultValues={config.lora}
fieldGroups={[
{

10
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 (
<DynamicForm<NetworkValidation>
onSubmit={onSubmit}
validationSchema={NetworkValidationSchema}
formId="Config_NetworkConfig"
defaultValues={{
...config.network,
ipv4Config: {

7
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={[
{

7
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 (
<DynamicForm<PowerValidation>
onSubmit={onSubmit}
validationSchema={PowerValidationSchema}
formId="Config_PowerConfig"
defaultValues={config.power}
fieldGroups={[
{

402
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<KeyState>(() => ({
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<HTMLInputElement>,
) => {
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<HTMLInputElement>,
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 (
<>
<DynamicForm<SecurityValidation>
onSubmit={() => {}}
submitType="onSubmit"
<DynamicForm<RawSecurity>
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<HTMLInputElement>) => {
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}
/>
</>

30
src/components/PageComponents/Config/Security/securityReducer.tsx

@ -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;
}
}

35
src/components/PageComponents/Config/Security/types.ts

@ -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"];

13
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 (

2
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;

7
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 (
<DynamicForm<AmbientLightingValidation>
onSubmit={onSubmit}
validationSchema={AmbientLightingValidationSchema}
formId="ModuleConfig_AmbientLightingConfig"
defaultValues={moduleConfig.ambientLighting}
fieldGroups={[
{

7
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 (
<DynamicForm<AudioValidation>
onSubmit={onSubmit}
validationSchema={AudioValidationSchema}
formId="ModuleConfig_AudioConfig"
defaultValues={moduleConfig.audio}
fieldGroups={[
{

7
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 (
<DynamicForm<CannedMessageValidation>
onSubmit={onSubmit}
validationSchema={CannedMessageValidationSchema}
formId="ModuleConfig_CannedMessageConfig"
defaultValues={moduleConfig.cannedMessage}
fieldGroups={[
{

19
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 (
<DynamicForm<DetectionSensorValidation>
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",

7
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 (
<DynamicForm<ExternalNotificationValidation>
onSubmit={onSubmit}
validationSchema={ExternalNotificationValidationSchema}
formId="ModuleConfig_ExternalNotificationConfig"
defaultValues={moduleConfig.externalNotification}
fieldGroups={[
{

7
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 (
<DynamicForm<MqttValidation>
onSubmit={onSubmit}
validationSchema={MqttValidationSchema}
formId="ModuleConfig_MqttConfig"
defaultValues={moduleConfig.mqtt}
fieldGroups={[
{

7
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 (
<DynamicForm<NeighborInfoValidation>
onSubmit={onSubmit}
validationSchema={NeighborInfoValidationSchema}
formId="ModuleConfig_NeighborInfoConfig"
defaultValues={moduleConfig.neighborInfo}
fieldGroups={[
{

7
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 (
<DynamicForm<PaxcounterValidation>
onSubmit={onSubmit}
validationSchema={PaxcounterValidationSchema}
formId="ModuleConfig_PaxcounterConfig"
defaultValues={moduleConfig.paxcounter}
fieldGroups={[
{

7
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 (
<DynamicForm<RangeTestValidation>
onSubmit={onSubmit}
validationSchema={RangeTestValidationSchema}
formId="ModuleConfig_RangeTestConfig"
defaultValues={moduleConfig.rangeTest}
fieldGroups={[
{

7
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 (
<DynamicForm<SerialValidation>
onSubmit={onSubmit}
validationSchema={SerialValidationSchema}
formId="ModuleConfig_SerialConfig"
defaultValues={moduleConfig.serial}
fieldGroups={[
{

7
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 (
<DynamicForm<StoreForwardValidation>
onSubmit={onSubmit}
validationSchema={StoreForwardValidationSchema}
formId="ModuleConfig_StoreForwardConfig"
defaultValues={moduleConfig.storeForward}
fieldGroups={[
{

7
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 (
<DynamicForm<TelemetryValidation>
onSubmit={onSubmit}
validationSchema={TelemetryValidationSchema}
formId="ModuleConfig_TelemetryConfig"
defaultValues={moduleConfig.telemetry}
fieldGroups={[
{

43
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) => {
)}
>
<img
src="Logo.svg"
src="/Logo.svg"
alt={t("app.logo")}
className="size-10 flex-shrink-0 rounded-xl"
/>
@ -162,21 +167,23 @@ export const Sidebar = ({ children }: SidebarProps) => {
label={t("navigation.title")}
className="mt-4 px-0"
>
{pages.map((link) => (
<SidebarButton
key={link.name}
count={link.count}
label={link.name}
Icon={link.icon}
onClick={() => {
if (myNode !== undefined) {
setActivePage(link.page);
}
}}
active={link.page === activePage}
disabled={myNode === undefined}
/>
))}
{pages.map((link) => {
return (
<SidebarButton
key={link.name}
count={link.count}
label={link.name}
Icon={link.icon}
onClick={() => {
if (myNode !== undefined) {
navigate({ to: `/${link.page}` });
}
}}
active={link.page === pathname}
disabled={myNode === undefined}
/>
);
})}
</SidebarSection>
<div

69
src/components/UI/ErrorPage.tsx

@ -3,51 +3,58 @@ import { ExternalLink } from "lucide-react";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Link } from "@components/UI/Typography/Link.tsx";
import { P } from "@components/UI/Typography/P.tsx";
import { Trans, useTranslation } from "react-i18next";
export function ErrorPage({ error }: { error: Error }) {
if (!error) {
return null;
}
const { t } = useTranslation();
return (
<article className="w-full h-screen overflow-y-auto dark:bg-background-primary dark:text-text-primary">
<article className="w-full h-screen overflow-y-auto bg-background-primary text-text-primary">
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center dark:bg-background-primary text-slate-900 dark:text-text-primary">
<div>
<Heading as="h2" className="text-text-primary">
This is a little embarrassing...
{t("errorPage.title")}
</Heading>
<P>
We are really sorry but an error occurred in the web client that
caused it to crash. <br />
This is not supposed to happen, and we are working hard to fix it.
{t("errorPage.description1")}
</P>
<P>
The best way to prevent this from happening again to you or anyone
else is to report the issue to us.
{t("errorPage.description2")}
</P>
<P>Please include the following information in your report:</P>
<ul className="list-disc list-inside text-sm">
<li>What you were doing when the error occurred</li>
<li>What you expected to happen</li>
<li>What actually happened</li>
<li>Any other relevant information</li>
<li>{t("errorPage.reportSteps.step1")}</li>
<li>{t("errorPage.reportSteps.step2")}</li>
<li>{t("errorPage.reportSteps.step3")}</li>
<li>{t("errorPage.reportSteps.step4")}</li>
</ul>
<P>
You can report the issue to our{" "}
<Link
href={newGithubIssueUrl({
repoUrl: "https://github.com/meshtastic/web",
template: "bug.yml",
title: "[Bug]: An unhandled error occurred. <Add details here>",
logs: error?.stack,
})}
>
Github
</Link>
<Trans
i18nKey="errorPage.reportLink"
components={[
<Link
key="github"
href={newGithubIssueUrl({
repoUrl: "https://github.com/meshtastic/web",
template: "bug.yml",
title:
"[Bug]: An unhandled error occurred. <Add details here>",
logs: error?.stack,
})}
/>,
]}
/>
<ExternalLink size={24} className="inline-block ml-2" />
</P>
<P>
Return to the <Link href="/">dashboard</Link>
<Trans
i18nKey="errorPage.dashboardLink"
components={[<Link key="dashboard" href="/" />]}
/>
</P>
</div>
@ -60,22 +67,26 @@ export function ErrorPage({ error }: { error: Error }) {
</div>
</section>
<details className="mt-8 px-4 md:px-8 text-lg md:text-xl space-y-2 text-md whitespace-pre-wrap break-all">
<summary className="cursor-pointer">Error Details</summary>
<summary className="cursor-pointer">
{t("errorPage.detailsSummary")}
</summary>
<span className="text-sm mt-4">
{error?.message && (
<>
<label htmlFor="message">Error message:</label>
<label htmlFor="message">
{t("errorPage.errorMessageLabel")}
</label>
<p
id="message"
className="text-slate-400 break-words overflow-wrap"
>
{error.message}
</p>
</>
</> // TODO: Use Trans for the label and message together?
)}
{error?.stack && (
<>
<label htmlFor="stack">Stack trace:</label>
<label htmlFor="stack">{t("errorPage.stackTraceLabel")}</label>
<p
id="stack"
className="text-slate-400 break-words overflow-wrap"
@ -85,7 +96,9 @@ export function ErrorPage({ error }: { error: Error }) {
</>
)}
{!error?.message && !error?.stack && (
<p className="text-slate-400">{error.toString()}</p>
<p className="text-slate-400">
{t("errorPage.fallbackError", { error: error.toString() })}
</p>
)}
</span>
</details>

11
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<HTMLElement> {
actionButtons: ActionButton[];
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (
event: React.ChangeEventHandler<HTMLInputElement> | undefined,
) => void;
inputChange: React.ChangeEventHandler<HTMLInputElement>;
showPasswordToggle?: boolean;
showCopyButton?: boolean;
disabled?: boolean;
@ -57,10 +54,10 @@ const Generator = (
...props
}: GeneratorProps,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(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,

16
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) => (
<a
href={href}
<RouterLink
to={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
@ -17,5 +21,5 @@ export const Link = ({ href, children, className }: LinkProps) => (
)}
>
{children}
</a>
</RouterLink>
);

13
src/core/stores/deviceStore.ts

@ -35,7 +35,6 @@ export interface Device {
>;
nodeErrors: Map<number, NodeError>;
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<PrivateDeviceState>((set, get) => ({
metadata: new Map(),
traceroutes: new Map(),
connection: undefined,
activePage: "messages",
activeNode: 0,
waypoints: [],
dialog: {
@ -318,16 +315,6 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
setActivePage: (page) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (device) {
device.activePage = page;
}
}),
);
},
setPendingSettingsChanges: (state) => {
set(
produce<DeviceState>((draft) => {

12
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<MessageStore>()(
}));
},
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) => {

7
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);

66
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"]);
});
});

19
src/core/utils/dotPath.ts

@ -0,0 +1,19 @@
export type DotPath = { [key: string]: unknown } | unknown[];
export const dotPaths = <T extends DotPath>(
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}`]
);
};

82
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 }) => <MapProvider>{children}</MapProvider>,
...options,
});
// a root route for the test router.
const rootRoute = new RootRoute({
component: () => (
<>
<Outlet />
</>
),
});
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> {
initialEntries?: string[];
ui?: ReactElement;
}
let currentRouter: ReturnType<typeof createRouter> | 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 }) => <div>Test Error: {error.message}</div>,
});
currentRouter = router; // Store the router instance for access in tests
const Wrapper: FunctionComponent<{ children?: ReactNode }> = (
{ children },
) => {
return (
<>
<RouterProvider router={router} />
{children}
</>
);
};
const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
return {
...renderResult,
router,
};
};
export * from "@testing-library/react";
export { customRender as render };
export const getTestRouter = () => currentRouter;

31
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."
}
}
}

14
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"
}
}
}

4
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"

6
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",

18
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. <br /> 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</0>",
"dashboardLink": "Return to the <0>dashboard</0>",
"detailsSummary": "Error Details",
"errorMessageLabel": "Error message:",
"stackTraceLabel": "Stack trace:",
"fallbackError": "{{error}}"
},
"footer": {
"text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>",
"commitSha": "Commit SHA: {{sha}}"

18
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<typeof createRouter>;
}
}
const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);
enableMapSet();
const router = createRouter({
routeTree,
});
root.render(
<StrictMode>
<Suspense fallback={null}>
<App />
</Suspense>,
<RouterProvider router={router} />
</Suspense>
</StrictMode>,
);

72
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<string>("");
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 (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("messagesPage.selectChatPrompt")}
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
}
@ -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 = () => {
)
: (
<div className="p-4 text-center text-slate-400 italic">
{t("messagesPage.sendMessagePrompt")}
{t("sendMessage.sendButton", { ns: "messages" })}
</div>
)}
</div>

59
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<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": []
}
}
}
ROUTE_MANIFEST_END */

79
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 };

32
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();

141
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);
}
});
});

85
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<Protobuf.Channel.Channel, keyof Message | "settings"> {
@IsNumber()
index: number;
settings: Channel_SettingsValidation;
@IsEnum(Protobuf.Channel.Channel_Role)
role: Protobuf.Channel.Channel_Role;
}
export class Channel_SettingsValidation
implements Omit<Protobuf.Channel.ChannelSettings, keyof Message | "psk"> {
@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,
});
}

24
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<typeof BluetoothValidationSchema>;

63
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<Protobuf.Config.Config_DeviceConfig, keyof Message> {
@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<typeof DeviceValidationSchema>;

70
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<Protobuf.Config.Config_DisplayConfig, keyof Message> {
@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<typeof DisplayValidationSchema>;

92
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<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled"> {
@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<typeof LoRaValidationSchema>;

24
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<typeof NetworkValidationSchema>;

62
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<typeof PositionValidationSchema>;

49
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<typeof PowerValidationSchema>;

113
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);
}
});
});

79
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<KeyT>(
keyMaker: (optional: boolean) => ZodType<KeyT>,
) {
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<typeof RawSecuritySchema>;
@IsBoolean()
serialEnabled: boolean;
}
export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema);
export type ParsedSecurity = z.infer<typeof ParsedSecuritySchema>;

37
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
>;

38
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<Protobuf.ModuleConfig.ModuleConfig_AudioConfig, keyof Message> {
@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<typeof AudioValidationSchema>;

65
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
>;

54
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
>;

77
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
>;

81
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<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> {
@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<typeof MqttValidationSchema>;

19
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<Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig, keyof Message> {
@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
>;

25
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<Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig, keyof Message> {
@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<typeof PaxcounterValidationSchema>;

21
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<Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig, keyof Message> {
@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<typeof RangeTestValidationSchema>;

49
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<Protobuf.ModuleConfig.ModuleConfig_SerialConfig, keyof Message> {
@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<typeof SerialValidationSchema>;

37
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
>;

55
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<Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig, keyof Message> {
@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
>;

174
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);
}
});
});
});

70
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<Uint8Array> =>
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,
};
}

22
src/validation/rasterSource.ts

@ -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;
}

4
src/validation/validate.ts

@ -1,7 +1,7 @@
import { ZodError, ZodSchema } from "zod";
import { ZodError, ZodType } from "zod/v4";
export function validateSchema<T>(
schema: ZodSchema<T>,
schema: ZodType<T>,
data: unknown,
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
const result = schema.safeParse(data);

2
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"],
},
});

Loading…
Cancel
Save