diff --git a/package.json b/package.json index 4ad78d8d..226f72dc 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "@radix-ui/react-tooltip": "^1.2.4", "@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", @@ -82,7 +81,7 @@ "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": { diff --git a/src/components/Form/DynamicForm.test.tsx b/src/components/Form/DynamicForm.test.tsx new file mode 100644 index 00000000..dd408b10 --- /dev/null +++ b/src/components/Form/DynamicForm.test.tsx @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx"; +import { DynamicForm } from "./DynamicForm.tsx"; +import { z } from "zod/v4"; +import { useAppStore } from "@core/stores/appStore.ts"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key), + }), +})); + +const addErrorMock = vi.fn(); +const removeErrorMock = vi.fn(); + +vi.mock("@core/stores/appStore.ts", () => ({ + useAppStore: () => ({ + addError: addErrorMock, + removeError: removeErrorMock, + }), +})); + +describe("DynamicForm", () => { + const schema = z.object({ + name: z.string().min(3, { message: "Too short" }), + }); + + const fieldGroups = [ + { + label: "Test Group", + description: "Testing validation", + fields: [ + { + type: "text", + id: "name", + name: "name", + label: "Name", + description: "Enter your name", + properties: {}, + }, + ], + }, + ]; + + it("shows validation error when input is too short", async () => { + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + + const error = await screen.findByText( + "formValidation.tooSmall.string", + ); + expect(error).toBeVisible(); + }); + + it("clears validation error when input becomes valid", async () => { + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + expect( + await screen.findByText("formValidation.tooSmall.string"), + ).toBeVisible(); + + fireEvent.input(input, { target: { value: "abcd" } }); + await waitFor(() => + expect( + screen.queryByText("formValidation.tooSmall.string"), + ).toBeNull() + ); + }); + + it("calls onSubmit when form is valid onChange", async () => { + const onSubmit = vi.fn(); + render( + > + onSubmit={onSubmit} + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + + const input = screen.getByLabelText("Name") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "ab" } }); + expect( + await screen.findByText("formValidation.tooSmall.string"), + ).toBeVisible(); + + fireEvent.input(input, { target: { value: "abcd" } }); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + expect(onSubmit).toHaveBeenCalledWith( + { name: "abcd" }, + expect.any(Object), + ); + }); + + it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => { + const onSubmit = vi.fn(); + render( + > + onSubmit={onSubmit} + submitType="onSubmit" + hasSubmitButton + validationSchema={schema} + defaultValues={{ name: "" }} + fieldGroups={fieldGroups} + />, + ); + + const btn = screen.getByRole("button", { name: /submit/i }); + expect(btn).toBeInTheDocument(); + + fireEvent.input(screen.getByLabelText("Name"), { target: { value: "ab" } }); + await screen.findByText("formValidation.tooSmall.string"); + fireEvent.click(btn); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.input(screen.getByLabelText("Name"), { + target: { value: "abcd" }, + }); + await waitFor(() => + expect(screen.queryByText("formValidation.tooSmall.string")).toBeNull() + ); + fireEvent.click(btn); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object)); + }); + + it("renders defaultValues correctly", () => { + render( + + onSubmit={vi.fn()} + // no validationSchema + defaultValues={{ name: "Alice" }} + fieldGroups={[ + { + label: "Group", + description: "", + fields: [ + { + type: "text", + name: "name", + label: "Name", + description: "", + properties: {}, + }, + ], + }, + ]} + />, + ); + const input = screen.getByLabelText("Name") as HTMLInputElement; + expect(input.value).toBe("Alice"); + }); + + it("toggles disabled state based on disabledBy rules", async () => { + const schema = z.object({ + enable: z.boolean(), + follow: z.string(), + }); + render( + > + onSubmit={vi.fn()} + validationSchema={schema} + defaultValues={{ enable: false, follow: "" }} + fieldGroups={[ + { + label: "Group", + description: "", + fields: [ + { + type: "toggle", + name: "enable", + label: "enable", + description: "", + }, + { + type: "text", + name: "follow", + label: "follow", + description: "", + disabledBy: [{ fieldName: "enable" }], + properties: {}, + }, + ], + }, + ]} + />, + ); + const enable = screen.getByRole("switch", { + name: "enable", + }) as HTMLInputElement; + + const follow = screen.getByLabelText("follow") as HTMLInputElement; + await waitFor(() => { + expect(enable.getAttribute("aria-checked")).toBe("false"); + expect(follow).toBeDisabled(); + }); + + fireEvent.click(enable); + await waitFor(() => { + expect(enable.getAttribute("aria-checked")).toBe("true"); + expect(follow).not.toBeDisabled(); + }); + }); + + it("always calls onSubmit onChange when no validationSchema is provided", async () => { + const onSubmit = vi.fn(); + render( + + onSubmit={onSubmit} + // no validationSchema + defaultValues={{ foo: "" }} + fieldGroups={[ + { + label: "G", + description: "", + fields: [ + { + type: "text", + name: "foo", + label: "Foo", + description: "", + properties: {}, + }, + ], + }, + ]} + />, + ); + const input = screen.getByLabelText("Foo") as HTMLInputElement; + fireEvent.input(input, { target: { value: "bar" } }); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object)); + }); + }); + + it("syncs errors to appStore when formId is set", async () => { + const { addError, removeError } = useAppStore(); + const schema = z.object({ foo: z.string().min(2) }); + const groups = [ + { + label: "G", + description: "", + fields: [ + { + type: "text", + name: "foo", + label: "Foo", + description: "", + properties: {}, + }, + ], + }, + ]; + + render( + > + onSubmit={vi.fn()} + formId="myForm" + validationSchema={schema} + defaultValues={{ foo: "" }} + fieldGroups={groups} + />, + ); + const input = screen.getByLabelText("Foo") as HTMLInputElement; + + fireEvent.input(input, { target: { value: "a" } }); + await screen.findByText(/tooSmall/i); + + expect(addError).toHaveBeenCalledWith("foo", ""); + expect(addError).toHaveBeenCalledWith("myForm", ""); + + fireEvent.input(input, { target: { value: "abc" } }); + await waitFor(() => { + expect(removeError).toHaveBeenCalledWith("foo"); + expect(removeError).toHaveBeenCalledWith("myForm"); + }); + }); +}); diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index dfdf1694..e7f91f51 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -9,11 +9,19 @@ import { type Control, type DefaultValues, type FieldValues, + FormProvider, + get, type Path, type SubmitHandler, useForm, } from "react-hook-form"; import { Heading } from "@components/UI/Typography/Heading.tsx"; +import { ZodType } from "zod/v4"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { createZodResolver } from "@components/Form/createZodResolver.ts"; +import { useAppStore } from "@core/stores/appStore.ts"; +import { dotPaths } from "@core/utils/dotPath.ts"; interface DisabledBy { fieldName: Path; @@ -51,6 +59,8 @@ export interface DynamicFormProps { validationText?: string; fields: FieldProps[]; }[]; + validationSchema?: ZodType; + formId?: string; } export function DynamicForm({ @@ -59,11 +69,45 @@ export function DynamicForm({ hasSubmitButton, defaultValues, fieldGroups, + validationSchema, + formId, }: DynamicFormProps) { - const { handleSubmit, control, getValues } = useForm({ - mode: submitType, + const { t } = useTranslation(); + const { + addError, + removeError, + } = useAppStore(); + + const methods = useForm< + T + >({ + mode: "onChange", defaultValues: defaultValues, + resolver: validationSchema + ? createZodResolver(validationSchema) + : undefined, + shouldFocusError: false, }); + const { handleSubmit, control, getValues, formState } = methods; + + useEffect(() => { + const errorKeys = Object.keys(formState.errors); + if (formId) { + if ( + errorKeys.length === 0 + ) { + dotPaths(getValues()).forEach((key) => { + removeError(key); + }); + removeError(formId); + } else { + errorKeys.forEach((key) => { + addError(key, ""); + }); + addError(formId, ""); + } + } + }, [formState.errors]); const isDisabled = ( disabledBy?: DisabledBy[], @@ -86,46 +130,66 @@ export function DynamicForm({ }; return ( -
- {fieldGroups.map((fieldGroup) => ( -
-
- - {fieldGroup.label} - - {fieldGroup.description} - {fieldGroup?.notes} -
+ + + {fieldGroups.map((fieldGroup) => ( +
+
+ + {fieldGroup.label} + + {fieldGroup.description} + {fieldGroup?.notes} +
- {fieldGroup.fields.map((field) => { - return ( - - - - ); - })} -
- ))} - {hasSubmitButton && ( - - )} - + {fieldGroup.fields.map((field) => { + const error = get(formState.errors, field.name as string); + return ( + + + + ); + })} +
+ ))} + {hasSubmitButton && ( + + )} + + ); } diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index 8cb92e07..ea5cf51a 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -4,9 +4,9 @@ import type { } from "@components/Form/DynamicForm.tsx"; import type { ButtonVariant } from "../UI/Button.tsx"; import { Generator } from "@components/UI/Generator.tsx"; -import type { ChangeEventHandler } from "react"; -import { Controller, type FieldValues } from "react-hook-form"; +import { Controller, type FieldValues, useFormContext } from "react-hook-form"; import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts"; +import { useEffect } from "react"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { type: "passwordGenerator"; @@ -14,8 +14,8 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps { hide?: boolean; bits?: { text: string; value: string; key: string }[]; devicePSKBitCount: number; - inputChange: ChangeEventHandler | undefined; - selectChange: (event: string) => void; + inputChange?: React.ChangeEventHandler; + selectChange?: (event: string) => void; actionButtons: { text: string; onClick: React.MouseEventHandler; @@ -32,19 +32,27 @@ export function PasswordGenerator({ disabled, }: GenericFormElementProps>) { const { isVisible } = usePasswordVisibilityToggle(); + const { trigger } = useFormContext(); + + useEffect(() => { + trigger(field.name); + }, [field.devicePSKBitCount, field.name, trigger]); return ( ( + render={({ field: { value, onChange, ...rest } }) => ( { + if (field.inputChange) field.inputChange(e); + onChange(e); + }} + selectChange={field.selectChange ?? (() => {})} value={value} variant={field.validationText ? "invalid" : "default"} actionButtons={field.actionButtons} diff --git a/src/components/Form/createZodResolver.ts b/src/components/Form/createZodResolver.ts new file mode 100644 index 00000000..09ca42bc --- /dev/null +++ b/src/components/Form/createZodResolver.ts @@ -0,0 +1,64 @@ +import type { ZodType } from "zod/v4"; +import type { + FieldError, + FieldValues, + Resolver, + ResolverOptions, + ResolverResult, +} from "react-hook-form"; + +export function createZodResolver( + schema: ZodType, +): Resolver { + return ( + values: T, + _context: unknown, + _options?: ResolverOptions, + ): ResolverResult => { + const result = schema.safeParse(values); + if (result.success) { + return { + values: result.data, + errors: {}, + }; + } + + const errors: Record< + string, + FieldError & { params?: Record } + > = {}; + + for (const issue of result.error.issues) { + const { path, code, message, ...params } = issue; + const key = path.join("."); + + const suffix = "format" in params + ? params.format + : "origin" in params + ? params.origin + : "expected" in params + ? params.expected + : ""; + + const newCode = code.replace( + /_([a-z])/g, + (_, char) => char.toUpperCase(), + ) + (suffix ? `.${suffix}` : ""); + + const fieldError: FieldError & { params?: Record } = { + type: newCode, + message: message, + ...(Object.keys(params).length ? { params } : {}), + }; + + if (!errors[key]) { + errors[key] = fieldError; + } + } + + return { + values: {} as T, + errors, + }; + }; +} diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index cc46b8d0..1d7b3c01 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -1,4 +1,4 @@ -import type { ChannelValidation } from "@app/validation/channel.ts"; +import { makeChannelSchema } from "@app/validation/channel.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useToast } from "@core/hooks/useToast.ts"; @@ -6,9 +6,10 @@ import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { fromByteArray, toByteArray } from "base64-js"; import cryptoRandomString from "crypto-random-string"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog.tsx"; +import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; +import { infer as zodInfer } from "zod/v4"; export interface SettingsPanelProps { channel: Protobuf.Channel.Channel; @@ -19,17 +20,25 @@ export const Channel = ({ channel }: SettingsPanelProps) => { const { t } = useTranslation(["channels", "ui", "dialog"]); const { toast } = useToast(); + const [preSharedDialogOpen, setPreSharedDialogOpen] = useState( + false, + ); const [pass, setPass] = useState( fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), ); - const [bitCount, setBits] = useState( + const [byteCount, setBytes] = useState( channel?.settings?.psk.length ?? 16, ); - const [validationText, setValidationText] = useState(); - const [preSharedDialogOpen, setPreSharedDialogOpen] = useState( - false, + + const ChannelValidationSchema = useMemo( + () => { + return makeChannelSchema(byteCount); + }, + [byteCount], ); + type ChannelValidation = zodInfer; + const onSubmit = (data: ChannelValidation) => { const channel = create(Protobuf.Channel.ChannelSchema, { ...data, @@ -43,8 +52,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => { }, }); connection?.setChannel(channel).then(() => { + console.debug(t("toast.savedChannel.title", { + ns: "ui", + channelName: channel.settings?.name, + })); toast({ - title: t("toast.savedChannel", { + title: t("toast.savedChannel.title", { ns: "ui", channelName: channel.settings?.name, }), @@ -54,15 +67,14 @@ export const Channel = ({ channel }: SettingsPanelProps) => { }; const preSharedKeyRegenerate = () => { - setPass( - btoa( - cryptoRandomString({ - length: bitCount ?? 0, - type: "alphanumeric", - }), - ), + const newPsk = btoa( + cryptoRandomString({ + length: byteCount ?? 0, + type: "alphanumeric", + }), ); - setValidationText(undefined); + setPass(newPsk); + setPreSharedDialogOpen(false); }; @@ -70,26 +82,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => { setPreSharedDialogOpen(true); }; - const validatePass = (input: string, count: number) => { - if (input.length % 4 !== 0 || toByteArray(input).length !== count) { - setValidationText( - t("validation.pskInvalid", { bits: count * 8 }), - ); - } else { - setValidationText(undefined); - } - }; - const inputChangeEvent = (e: React.ChangeEvent) => { - const psk = e.currentTarget?.value; - setPass(psk); - validatePass(psk, bitCount); + setPass(e.currentTarget?.value); }; const selectChangeEvent = (e: string) => { const count = Number.parseInt(e); - setBits(count); - validatePass(pass, count); + setBytes(count); }; return ( @@ -97,6 +96,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => { onSubmit={onSubmit} submitType="onSubmit" + validationSchema={ChannelValidationSchema} hasSubmitButton defaultValues={{ ...channel, @@ -141,8 +141,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => { id: "channel-psk", label: t("psk.label"), description: t("psk.description"), - validationText: validationText, - devicePSKBitCount: bitCount ?? 0, + devicePSKBitCount: byteCount ?? 0, inputChange: inputChangeEvent, selectChange: selectChangeEvent, actionButtons: [ diff --git a/src/components/PageComponents/Config/Bluetooth.tsx b/src/components/PageComponents/Config/Bluetooth.tsx index 96ef1765..efd1111c 100644 --- a/src/components/PageComponents/Config/Bluetooth.tsx +++ b/src/components/PageComponents/Config/Bluetooth.tsx @@ -1,63 +1,18 @@ -import { useAppStore } from "../../../core/stores/appStore.ts"; -import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts"; +import { + type BluetoothValidation, + BluetoothValidationSchema, +} from "@app/validation/config/bluetooth.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; export const Bluetooth = () => { const { config, setWorkingConfig } = useDevice(); - const { - hasErrors, - getErrorMessage, - hasFieldError, - addError, - removeError, - clearErrors, - } = useAppStore(); const { t } = useTranslation("deviceConfig"); - const [bluetoothPin, setBluetoothPin] = useState( - config?.bluetooth?.fixedPin.toString() ?? "", - ); - - const validateBluetoothPin = (pin: string) => { - // if empty show error they need a pin set - if (pin === "") { - return addError("fixedPin", t("bluetooth.validation.pinRequired")); - } - - // clear any existing errors - clearErrors(); - - // if it starts with 0 show error - if (pin[0] === "0") { - return addError( - "fixedPin", - t("bluetooth.validation.pinCannotStartWithZero"), - ); - } - // if it's not 6 digits show error - if (pin.length < 6) { - return addError("fixedPin", t("bluetooth.validation.pinMustBeSixDigits")); - } - - removeError("fixedPin"); - }; - - const bluetoothPinChangeEvent = (e: React.ChangeEvent) => { - const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6); - setBluetoothPin(numericValue); - validateBluetoothPin(numericValue); - }; - const onSubmit = (data: BluetoothValidation) => { - if (hasErrors()) { - return; - } - setWorkingConfig( create(Protobuf.Config.ConfigSchema, { payloadVariant: { @@ -71,6 +26,8 @@ export const Bluetooth = () => { return ( onSubmit={onSubmit} + validationSchema={BluetoothValidationSchema} + formId="Config_BluetoothConfig" defaultValues={config.bluetooth} fieldGroups={[ { @@ -89,12 +46,6 @@ export const Bluetooth = () => { name: "mode", label: t("bluetooth.pairingMode.label"), description: t("bluetooth.pairingMode.description"), - selectChange: (e) => { - if (e !== "1") { - setBluetoothPin(""); - removeError("fixedPin"); - } - }, disabledBy: [ { fieldName: "enabled", @@ -110,24 +61,6 @@ export const Bluetooth = () => { name: "fixedPin", label: t("bluetooth.pin.label"), description: t("bluetooth.pin.description"), - validationText: hasFieldError("fixedPin") - ? getErrorMessage("fixedPin") - : "", - inputChange: bluetoothPinChangeEvent, - disabledBy: [ - { - fieldName: "mode", - selector: Protobuf.Config.Config_BluetoothConfig_PairingMode - .FIXED_PIN, - invert: true, - }, - { - fieldName: "enabled", - }, - ], - properties: { - value: bluetoothPin, - }, }, ], }, diff --git a/src/components/PageComponents/Config/Device/index.tsx b/src/components/PageComponents/Config/Device/index.tsx index 2b53495e..68e4b8ad 100644 --- a/src/components/PageComponents/Config/Device/index.tsx +++ b/src/components/PageComponents/Config/Device/index.tsx @@ -1,4 +1,7 @@ -import type { DeviceValidation } from "@app/validation/config/device.ts"; +import { + type DeviceValidation, + DeviceValidationSchema, +} from "@app/validation/config/device.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -24,6 +27,8 @@ export const Device = () => { return ( onSubmit={onSubmit} + validationSchema={DeviceValidationSchema} + formId="Config_DeviceConfig" defaultValues={config.device} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Display.tsx b/src/components/PageComponents/Config/Display.tsx index 478aec32..0cda0f33 100644 --- a/src/components/PageComponents/Config/Display.tsx +++ b/src/components/PageComponents/Config/Display.tsx @@ -1,4 +1,7 @@ -import type { DisplayValidation } from "@app/validation/config/display.ts"; +import { + type DisplayValidation, + DisplayValidationSchema, +} from "@app/validation/config/display.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Display = () => { return ( onSubmit={onSubmit} + validationSchema={DisplayValidationSchema} + formId="Config_DisplayConfig" defaultValues={config.display} fieldGroups={[ { @@ -91,7 +96,7 @@ export const Display = () => { label: t("display.oledType.label"), description: t("display.oledType.description"), properties: { - enumValue: Protobuf.Config.Config_Displayjonfig_OledType, + enumValue: Protobuf.Config.Config_DisplayConfig_OledType, }, }, { diff --git a/src/components/PageComponents/Config/LoRa.tsx b/src/components/PageComponents/Config/LoRa.tsx index d8e3b7f7..cf3abdd5 100644 --- a/src/components/PageComponents/Config/LoRa.tsx +++ b/src/components/PageComponents/Config/LoRa.tsx @@ -1,4 +1,7 @@ -import type { LoRaValidation } from "@app/validation/config/lora.ts"; +import { + type LoRaValidation, + LoRaValidationSchema, +} from "@app/validation/config/lora.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const LoRa = () => { return ( onSubmit={onSubmit} + validationSchema={LoRaValidationSchema} + formId="Config_LoRaConfig" defaultValues={config.lora} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Network/index.tsx b/src/components/PageComponents/Config/Network/index.tsx index 9bfb2f54..3d944668 100644 --- a/src/components/PageComponents/Config/Network/index.tsx +++ b/src/components/PageComponents/Config/Network/index.tsx @@ -10,20 +10,12 @@ import { convertIpAddressToInt, } from "@core/utils/ip.ts"; import { Protobuf } from "@meshtastic/core"; -import { validateSchema } from "@app/validation/validate.ts"; import { useTranslation } from "react-i18next"; export const Network = () => { const { config, setWorkingConfig } = useDevice(); const { t } = useTranslation("deviceConfig"); - const onSubmit = (data: NetworkValidation) => { - const result = validateSchema(NetworkValidationSchema, data); - - if (!result.success) { - console.error("Validation errors:", result.errors); - } - setWorkingConfig( create(Protobuf.Config.ConfigSchema, { payloadVariant: { @@ -48,6 +40,8 @@ export const Network = () => { return ( onSubmit={onSubmit} + validationSchema={NetworkValidationSchema} + formId="Config_NetworkConfig" defaultValues={{ ...config.network, ipv4Config: { diff --git a/src/components/PageComponents/Config/Position.tsx b/src/components/PageComponents/Config/Position.tsx index 78841477..b306117c 100644 --- a/src/components/PageComponents/Config/Position.tsx +++ b/src/components/PageComponents/Config/Position.tsx @@ -2,7 +2,10 @@ import { type FlagName, usePositionFlags, } from "@core/hooks/usePositionFlags.ts"; -import type { PositionValidation } from "@app/validation/config/position.ts"; +import { + type PositionValidation, + PositionValidationSchema, +} from "@app/validation/config/position.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -41,6 +44,8 @@ export const Position = () => { data.positionFlags = flagsValue; return onSubmit(data); }} + validationSchema={PositionValidationSchema} + formId="Config_PositionConfig" defaultValues={config.position} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Power.tsx b/src/components/PageComponents/Config/Power.tsx index 5044278e..b81e1aaa 100644 --- a/src/components/PageComponents/Config/Power.tsx +++ b/src/components/PageComponents/Config/Power.tsx @@ -1,4 +1,7 @@ -import type { PowerValidation } from "@app/validation/config/power.ts"; +import { + type PowerValidation, + PowerValidationSchema, +} from "@app/validation/config/power.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Power = () => { return ( onSubmit={onSubmit} + validationSchema={PowerValidationSchema} + formId="Config_PowerConfig" defaultValues={config.power} fieldGroups={[ { diff --git a/src/components/PageComponents/Config/Security/Security.tsx b/src/components/PageComponents/Config/Security/Security.tsx index 533e0d84..5b3f6e77 100644 --- a/src/components/PageComponents/Config/Security/Security.tsx +++ b/src/components/PageComponents/Config/Security/Security.tsx @@ -2,116 +2,46 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx" import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useAppStore } from "@core/stores/appStore.ts"; import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.ts"; -import type { SecurityValidation } from "@app/validation/config/security.ts"; +import { + type ParsedSecurity, + type RawSecurity, + RawSecuritySchema, +} from "@app/validation/config/security.ts"; +import { useState } from "react"; import { create } from "@bufbuild/protobuf"; import { useDevice } from "@core/stores/deviceStore.ts"; import { Protobuf } from "@meshtastic/core"; import { fromByteArray, toByteArray } from "base64-js"; -import { useReducer } from "react"; -import { securityReducer } from "@components/PageComponents/Config/Security/securityReducer.tsx"; -import type { SecurityConfigInit } from "./types.ts"; import { useTranslation } from "react-i18next"; +type KeyState = { + publicKey: string; + privateKey: string; + privateKeyDialogOpen: boolean; +}; + export const Security = () => { const { config, setWorkingConfig, setDialogOpen } = useDevice(); - const { - hasErrors, - getErrorMessage, - hasFieldError, - addError, - removeError, - clearErrors, - } = useAppStore(); + const { removeError } = useAppStore(); const { t } = useTranslation("deviceConfig"); - const [state, dispatch] = useReducer(securityReducer, { - privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), - privateKeyVisible: false, - adminKeyVisible: [false, false, false], - privateKeyBitCount: config.security?.privateKey?.length ?? 32, - publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)), - adminKey: [ - fromByteArray(config.security?.adminKey?.at(0) ?? new Uint8Array(0)), - fromByteArray(config.security?.adminKey?.at(1) ?? new Uint8Array(0)), - fromByteArray(config.security?.adminKey?.at(2) ?? new Uint8Array(0)), - ], + const [keyState, setKeyState] = useState(() => ({ + publicKey: fromByteArray(config?.security?.publicKey ?? new Uint8Array(0)), + privateKey: fromByteArray( + config?.security?.privateKey ?? new Uint8Array(0), + ), privateKeyDialogOpen: false, - isManaged: config.security?.isManaged ?? false, - adminChannelEnabled: config.security?.adminChannelEnabled ?? false, - debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false, - serialEnabled: config.security?.serialEnabled ?? false, - }); - - const validateKey = ( - input: string, - count: number, - fieldName: "privateKey" | "adminKey", - fieldIndex?: number, - ) => { - const fieldNameKey = fieldName + (fieldIndex ?? ""); - try { - removeError(fieldNameKey); - if (fieldName === "privateKey" && input === "") { - addError(fieldNameKey, t("security.validation.privateKeyRequired")); - return; - } - - if (fieldName === "adminKey" && input === "") { - if ( - state.isManaged && - state.adminKey - .map((v, i) => (i === fieldIndex ? input : v)) - .every((s) => s === "") - ) { - addError("adminKey0", t("security.")); - } - - return; - } - - if (input.length % 4 !== 0) { - addError( - fieldNameKey, - fieldName === "privateKey" - ? t("security.validation.privateKeyMustBe256BitPsk") - : t("security.validation.adminKeyMustBe256BitPsk"), - ); - return; - } + })); - const decoded = toByteArray(input); - if (decoded.length !== count) { - addError( - fieldNameKey, - t("security.validation.enterValid256BitPsk", { - bits: count * 8, - }), - ); - return; - } - } catch (e) { - console.error(e); - addError( - fieldNameKey, - fieldName === "privateKey" - ? t("security.validation.invalidPrivateKeyFormat") - : t("security.validation.invalidAdminKeyFormat"), - ); - } - }; - - function setSecurityPayload(overrides: SecurityConfigInit) { - const base: SecurityConfigInit = { - isManaged: state.isManaged, - adminChannelEnabled: state.adminChannelEnabled, - debugLogApiEnabled: state.debugLogApiEnabled, - serialEnabled: state.serialEnabled, - privateKey: overrides?.privateKey ?? toByteArray(state.privateKey), - publicKey: overrides?.publicKey ?? toByteArray(state.publicKey), + const onSubmit = (data: RawSecurity) => { + const payload: ParsedSecurity = { + ...data, + privateKey: toByteArray(keyState.privateKey), + publicKey: toByteArray(keyState.publicKey), adminKey: [ - overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]), - overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]), - overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]), + toByteArray(data.adminKey.at(0) ?? ""), + toByteArray(data.adminKey.at(1) ?? ""), + toByteArray(data.adminKey.at(2) ?? ""), ], }; @@ -119,137 +49,79 @@ export const Security = () => { create(Protobuf.Config.ConfigSchema, { payloadVariant: { case: "security", - value: { ...base, ...overrides }, + value: payload, }, }), ); - } - + }; const pkiRegenerate = () => { - clearErrors(); const privateKey = getX25519PrivateKey(); - const publicKey = getX25519PublicKey(privateKey); - dispatch({ - type: "REGENERATE_PRIV_PUB_KEY", - payload: { - privateKey: fromByteArray(privateKey), - publicKey: fromByteArray(publicKey), - }, - }); + updatePublicKey(fromByteArray(privateKey)); - validateKey( - fromByteArray(privateKey), - state.privateKeyBitCount, - "privateKey", - ); + setKeyState((prev) => ({ + ...prev, + privateKey: fromByteArray(privateKey), + privateKeyDialogOpen: false, + })); - if (!hasErrors()) { - setSecurityPayload({ - privateKey: privateKey, - publicKey: publicKey, - }); - } + removeError("privateKey"); }; - const privateKeyInputChangeEvent = ( - e: React.ChangeEvent, - ) => { - const privateKeyB64String = e.target.value; - dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String }); - validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey"); - - const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String)); - dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) }); - - if (!hasErrors()) { - setSecurityPayload({ - privateKey: toByteArray(privateKeyB64String), + const updatePublicKey = (privateKey: string) => { + try { + const publicKey = fromByteArray( + getX25519PublicKey(toByteArray(privateKey)), + ); + setKeyState((prev) => ({ + ...prev, + privateKey: privateKey, publicKey: publicKey, - }); - } - }; - - const adminKeyInputChangeEvent = ( - e: React.ChangeEvent, - fieldIndex?: number, - ) => { - if (fieldIndex === undefined) return; - const psk = e.target.value; + })); - const payload = [ - fieldIndex === 0 ? psk : state.adminKey[0], - fieldIndex === 1 ? psk : state.adminKey[1], - fieldIndex === 2 ? psk : state.adminKey[2], - ] satisfies [string, string, string]; - - dispatch({ type: "SET_ADMIN_KEY", payload: payload }); - validateKey(psk, state.privateKeyBitCount, "adminKey", fieldIndex); - - if (!hasErrors()) { - setSecurityPayload({ - adminKey: payload.map(toByteArray) as [ - Uint8Array, - Uint8Array, - Uint8Array, - ], - }); + removeError("publicKey"); + } catch (_e) { + setKeyState((prev) => ({ + ...prev, + privateKey: privateKey, + })); } }; - const onToggleChange = ( - field: - | "isManaged" - | "adminChannelEnabled" - | "debugLogApiEnabled" - | "serialEnabled", - next: boolean, - ) => { - dispatch({ type: "SET_TOGGLE", field, payload: next }); - - if (field === "isManaged" && state.adminKey.every((s) => s === "")) { - if (next) { - // If enabling 'managed' and no admin keys are set - addError( - "adminKey0", - t("security.validation.adminKeyRequiredWhenManaged"), - ); - } else { - removeError("adminKey0"); - removeError("adminKey1"); - removeError("adminKey2"); - } - } - - if (!hasErrors()) { - setSecurityPayload({ - isManaged: field === "isManaged" ? next : state.isManaged, - adminChannelEnabled: field === "adminChannelEnabled" - ? next - : state.adminChannelEnabled, - debugLogApiEnabled: field === "debugLogApiEnabled" - ? next - : state.debugLogApiEnabled, - serialEnabled: field === "serialEnabled" ? next : state.serialEnabled, - }); - } - }; + const bits = [ + { + text: t("security.256bit"), + value: "32", + key: "bit256", + }, + ]; return ( <> - - onSubmit={() => {}} - submitType="onSubmit" + + onSubmit={onSubmit} + validationSchema={RawSecuritySchema} + formId="Config_SecurityConfig" defaultValues={{ ...config.security, ...{ - adminKey: state.adminKey, - privateKey: state.privateKey, - publicKey: state.publicKey, - adminChannelEnabled: config.security?.adminChannelEnabled ?? false, - isManaged: config.security?.isManaged ?? false, - debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false, - serialEnabled: config.security?.serialEnabled ?? false, + privateKey: fromByteArray( + config?.security?.privateKey ?? new Uint8Array(0), + ), + publicKey: fromByteArray( + config?.security?.publicKey ?? new Uint8Array(0), + ), + adminKey: [ + fromByteArray( + config?.security?.adminKey.at(0) ?? new Uint8Array(0), + ), + fromByteArray( + config?.security?.adminKey.at(1) ?? new Uint8Array(0), + ), + fromByteArray( + config?.security?.adminKey.at(2) ?? new Uint8Array(0), + ), + ], }, }} fieldGroups={[ @@ -263,28 +135,20 @@ export const Security = () => { name: "privateKey", label: t("security.privateKey.label"), description: t("security.privateKey.description"), - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - validationText: hasFieldError("privateKey") - ? getErrorMessage("privateKey") - : "", - devicePSKBitCount: state.privateKeyBitCount, - inputChange: privateKeyInputChangeEvent, - selectChange: () => {}, - hide: !state.privateKeyVisible, + bits, + devicePSKBitCount: 32, + hide: true, + inputChange: (e: React.ChangeEvent) => { + updatePublicKey(e.target.value); + }, actionButtons: [ { text: t("button.generate"), onClick: () => - dispatch({ - type: "SHOW_PRIVATE_KEY_DIALOG", - payload: true, - }), + setKeyState((prev) => ({ + ...prev, + privateKeyDialogOpen: true, + })), variant: "success", }, { @@ -294,9 +158,10 @@ export const Security = () => { }, ], properties: { - value: state.privateKey, showCopyButton: true, showPasswordToggle: true, + + value: keyState.privateKey, }, }, { @@ -306,8 +171,8 @@ export const Security = () => { disabled: true, description: t("security.publicKey.description"), properties: { - value: state.publicKey, showCopyButton: true, + value: keyState.publicKey, }, }, ], @@ -322,26 +187,14 @@ export const Security = () => { id: "adminKey0Input", label: t("security.primaryAdminKey.label"), description: t("security.primaryAdminKey.description"), - validationText: hasFieldError("adminKey0") - ? getErrorMessage("adminKey0") - : "", - inputChange: (e) => adminKeyInputChangeEvent(e, 0), - selectChange: () => {}, - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - devicePSKBitCount: state.privateKeyBitCount, - hide: !state.adminKeyVisible[0], + bits, + devicePSKBitCount: 32, + hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { - value: state.adminKey[0], showCopyButton: true, showPasswordToggle: true, }, @@ -352,26 +205,14 @@ export const Security = () => { id: "adminKey1Input", label: t("security.secondaryAdminKey.label"), description: t("security.secondaryAdminKey.description"), - validationText: hasFieldError("adminKey1") - ? getErrorMessage("adminKey1") - : "", - inputChange: (e) => adminKeyInputChangeEvent(e, 1), - selectChange: () => {}, - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - devicePSKBitCount: state.privateKeyBitCount, - hide: !state.adminKeyVisible[1], + bits, + devicePSKBitCount: 32, + hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { - value: state.adminKey[1], showCopyButton: true, showPasswordToggle: true, }, @@ -382,26 +223,14 @@ export const Security = () => { id: "adminKey2Input", label: t("security.tertiaryAdminKey.label"), description: t("security.tertiaryAdminKey.description"), - validationText: hasFieldError("adminKey2") - ? getErrorMessage("adminKey2") - : "", - inputChange: (e) => adminKeyInputChangeEvent(e, 2), - selectChange: () => {}, - bits: [ - { - text: t("security.256bit"), - value: "32", - key: "bit256", - }, - ], - devicePSKBitCount: state.privateKeyBitCount, - hide: !state.adminKeyVisible[2], + bits, + devicePSKBitCount: 32, + hide: true, actionButtons: [], disabledBy: [ { fieldName: "adminChannelEnabled", invert: true }, ], properties: { - value: state.adminKey[2], showCopyButton: true, showPasswordToggle: true, }, @@ -411,25 +240,12 @@ export const Security = () => { name: "isManaged", label: t("security.managed.label"), description: t("security.managed.description"), - inputChange: (e: boolean) => onToggleChange("isManaged", e), - properties: { - checked: state.isManaged, - }, - disabled: (hasFieldError("adminKey0") || - hasFieldError("adminKey1") || - hasFieldError("adminKey2")) && - !state.adminKey.every((s) => s === ""), }, { type: "toggle", name: "adminChannelEnabled", label: t("security.adminChannelEnabled.label"), description: t("security.adminChannelEnabled.description"), - inputChange: (e: boolean) => - onToggleChange("adminChannelEnabled", e), - properties: { - checked: state.adminChannelEnabled, - }, }, ], }, @@ -442,21 +258,12 @@ export const Security = () => { name: "debugLogApiEnabled", label: t("security.enableDebugLogApi.label"), description: t("security.enableDebugLogApi.description"), - inputChange: (e: boolean) => - onToggleChange("debugLogApiEnabled", e), - properties: { - checked: state.debugLogApiEnabled, - }, }, { type: "toggle", name: "serialEnabled", label: t("security.serialOutputEnabled.label"), description: t("security.serialOutputEnabled.description"), - inputChange: (e: boolean) => onToggleChange("serialEnabled", e), - properties: { - checked: state.serialEnabled, - }, }, ], }, @@ -468,9 +275,12 @@ export const Security = () => { title: t("pkiRegenerate.title"), description: t("pkiRegenerate.description"), }} - open={state.privateKeyDialogOpen} + open={keyState.privateKeyDialogOpen} onOpenChange={() => - dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })} + setKeyState((prev) => ({ + ...prev, + privateKeyDialogOpen: false, + }))} onSubmit={pkiRegenerate} /> diff --git a/src/components/PageComponents/Config/Security/securityReducer.tsx b/src/components/PageComponents/Config/Security/securityReducer.tsx deleted file mode 100644 index 4860ab4a..00000000 --- a/src/components/PageComponents/Config/Security/securityReducer.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { SecurityAction, SecurityState } from "./types.ts"; - -export function securityReducer( - state: SecurityState, - action: SecurityAction, -): SecurityState { - switch (action.type) { - case "SET_PRIVATE_KEY": - return { ...state, privateKey: action.payload }; - case "SET_PRIVATE_KEY_BIT_COUNT": - return { ...state, privateKeyBitCount: action.payload }; - case "SET_PUBLIC_KEY": - return { ...state, publicKey: action.payload }; - case "SET_ADMIN_KEY": - return { ...state, adminKey: action.payload }; - case "SHOW_PRIVATE_KEY_DIALOG": - return { ...state, privateKeyDialogOpen: action.payload }; - case "REGENERATE_PRIV_PUB_KEY": - return { - ...state, - privateKey: action.payload.privateKey, - publicKey: action.payload.publicKey, - privateKeyDialogOpen: false, - }; - case "SET_TOGGLE": - return { ...state, [action.field]: action.payload }; - default: - return state; - } -} diff --git a/src/components/PageComponents/Config/Security/types.ts b/src/components/PageComponents/Config/Security/types.ts deleted file mode 100644 index acfde8ce..00000000 --- a/src/components/PageComponents/Config/Security/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type MessageInitShape } from "@bufbuild/protobuf"; -import { Protobuf } from "@meshtastic/core"; - -export interface SecurityState { - privateKey: string; - privateKeyVisible: boolean; - adminKeyVisible: [boolean, boolean, boolean]; - privateKeyBitCount: number; - publicKey: string; - adminKey: [string, string, string]; - privateKeyDialogOpen: boolean; - isManaged: boolean; - adminChannelEnabled: boolean; - debugLogApiEnabled: boolean; - serialEnabled: boolean; -} - -export type SecurityAction = - | { type: "SET_PRIVATE_KEY"; payload: string } - | { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number } - | { type: "SET_PUBLIC_KEY"; payload: string } - | { type: "SET_ADMIN_KEY"; payload: [string, string, string] } - | { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean } - | { type: "SET_TOGGLE"; payload: boolean; field: string } - | { - type: "REGENERATE_PRIV_PUB_KEY"; - payload: { privateKey: string; publicKey: string }; - }; - -export type SecurityConfigInit = Extract< - MessageInitShape< - typeof Protobuf.Config.ConfigSchema - >["payloadVariant"], - { case: "security" } ->["value"]; diff --git a/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx b/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx index 405abc8d..51f9cc5d 100644 --- a/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx +++ b/src/components/PageComponents/ModuleConfig/AmbientLighting.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { AmbientLightingValidation } from "@app/validation/moduleConfig/ambientLighting.ts"; +import { + type AmbientLightingValidation, + AmbientLightingValidationSchema, +} from "@app/validation/moduleConfig/ambientLighting.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -23,6 +26,8 @@ export const AmbientLighting = () => { return ( onSubmit={onSubmit} + validationSchema={AmbientLightingValidationSchema} + formId="ModuleConfig_AmbientLightingConfig" defaultValues={moduleConfig.ambientLighting} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Audio.tsx b/src/components/PageComponents/ModuleConfig/Audio.tsx index a87361e9..4db01275 100644 --- a/src/components/PageComponents/ModuleConfig/Audio.tsx +++ b/src/components/PageComponents/ModuleConfig/Audio.tsx @@ -1,4 +1,7 @@ -import type { AudioValidation } from "@app/validation/moduleConfig/audio.ts"; +import { + type AudioValidation, + AudioValidationSchema, +} from "@app/validation/moduleConfig/audio.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Audio = () => { return ( onSubmit={onSubmit} + validationSchema={AudioValidationSchema} + formId="ModuleConfig_AudioConfig" defaultValues={moduleConfig.audio} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx b/src/components/PageComponents/ModuleConfig/CannedMessage.tsx index f26f8c51..80221028 100644 --- a/src/components/PageComponents/ModuleConfig/CannedMessage.tsx +++ b/src/components/PageComponents/ModuleConfig/CannedMessage.tsx @@ -1,4 +1,7 @@ -import type { CannedMessageValidation } from "@app/validation/moduleConfig/cannedMessage.ts"; +import { + type CannedMessageValidation, + CannedMessageValidationSchema, +} from "@app/validation/moduleConfig/cannedMessage.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const CannedMessage = () => { return ( onSubmit={onSubmit} + validationSchema={CannedMessageValidationSchema} + formId="ModuleConfig_CannedMessageConfig" defaultValues={moduleConfig.cannedMessage} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx b/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx index 138e6cdc..1ce0aaee 100644 --- a/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx +++ b/src/components/PageComponents/ModuleConfig/DetectionSensor.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { DetectionSensorValidation } from "@app/validation/moduleConfig/detectionSensor.ts"; +import { + type DetectionSensorValidation, + DetectionSensorValidationSchema, +} from "@app/validation/moduleConfig/detectionSensor.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -23,6 +26,8 @@ export const DetectionSensor = () => { return ( onSubmit={onSubmit} + validationSchema={DetectionSensorValidationSchema} + formId="ModuleConfig_DetectionSensorConfig" defaultValues={moduleConfig.detectionSensor} fieldGroups={[ { @@ -96,17 +101,21 @@ export const DetectionSensor = () => { ], }, { - type: "toggle", - name: "detectionTriggeredHigh", - label: t("detectionSensor.detectionTriggeredHigh.label"), + type: "select", + name: "detectionTriggerType", + label: t("detectionSensor.detectionTriggerType.label"), description: t( - "detectionSensor.detectionTriggeredHigh.description", + "detectionSensor.detectionTriggerType.description", ), disabledBy: [ { fieldName: "enabled", }, ], + properties: { + enumValue: Protobuf.ModuleConfig + .ModuleConfig_DetectionSensorConfig_TriggerType, + }, }, { type: "toggle", diff --git a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx b/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx index 470ab5ea..61796342 100644 --- a/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx +++ b/src/components/PageComponents/ModuleConfig/ExternalNotification.tsx @@ -1,4 +1,7 @@ -import type { ExternalNotificationValidation } from "@app/validation/moduleConfig/externalNotification.ts"; +import { + type ExternalNotificationValidation, + ExternalNotificationValidationSchema, +} from "@app/validation/moduleConfig/externalNotification.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const ExternalNotification = () => { return ( onSubmit={onSubmit} + validationSchema={ExternalNotificationValidationSchema} + formId="ModuleConfig_ExternalNotificationConfig" defaultValues={moduleConfig.externalNotification} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/MQTT.tsx b/src/components/PageComponents/ModuleConfig/MQTT.tsx index 51007f97..e9587e89 100644 --- a/src/components/PageComponents/ModuleConfig/MQTT.tsx +++ b/src/components/PageComponents/ModuleConfig/MQTT.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { MqttValidation } from "@app/validation/moduleConfig/mqtt.ts"; +import { + type MqttValidation, + MqttValidationSchema, +} from "@app/validation/moduleConfig/mqtt.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -29,6 +32,8 @@ export const MQTT = () => { return ( onSubmit={onSubmit} + validationSchema={MqttValidationSchema} + formId="ModuleConfig_MqttConfig" defaultValues={moduleConfig.mqtt} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx b/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx index 9e8c75a8..eabb9207 100644 --- a/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx +++ b/src/components/PageComponents/ModuleConfig/NeighborInfo.tsx @@ -1,5 +1,8 @@ import { useDevice } from "@core/stores/deviceStore.ts"; -import type { NeighborInfoValidation } from "@app/validation/moduleConfig/neighborInfo.ts"; +import { + type NeighborInfoValidation, + NeighborInfoValidationSchema, +} from "@app/validation/moduleConfig/neighborInfo.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { Protobuf } from "@meshtastic/core"; @@ -23,6 +26,8 @@ export const NeighborInfo = () => { return ( onSubmit={onSubmit} + validationSchema={NeighborInfoValidationSchema} + formId="ModuleConfig_NeighborInfoConfig" defaultValues={moduleConfig.neighborInfo} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Paxcounter.tsx b/src/components/PageComponents/ModuleConfig/Paxcounter.tsx index d19e398f..c11840ad 100644 --- a/src/components/PageComponents/ModuleConfig/Paxcounter.tsx +++ b/src/components/PageComponents/ModuleConfig/Paxcounter.tsx @@ -1,4 +1,7 @@ -import type { PaxcounterValidation } from "@app/validation/moduleConfig/paxcounter.ts"; +import { + type PaxcounterValidation, + PaxcounterValidationSchema, +} from "@app/validation/moduleConfig/paxcounter.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Paxcounter = () => { return ( onSubmit={onSubmit} + validationSchema={PaxcounterValidationSchema} + formId="ModuleConfig_PaxcounterConfig" defaultValues={moduleConfig.paxcounter} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/RangeTest.tsx b/src/components/PageComponents/ModuleConfig/RangeTest.tsx index 96787e6e..5960508d 100644 --- a/src/components/PageComponents/ModuleConfig/RangeTest.tsx +++ b/src/components/PageComponents/ModuleConfig/RangeTest.tsx @@ -1,4 +1,7 @@ -import type { RangeTestValidation } from "@app/validation/moduleConfig/rangeTest.ts"; +import { + type RangeTestValidation, + RangeTestValidationSchema, +} from "@app/validation/moduleConfig/rangeTest.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const RangeTest = () => { return ( onSubmit={onSubmit} + validationSchema={RangeTestValidationSchema} + formId="ModuleConfig_RangeTestConfig" defaultValues={moduleConfig.rangeTest} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Serial.tsx b/src/components/PageComponents/ModuleConfig/Serial.tsx index f633418b..d98684b5 100644 --- a/src/components/PageComponents/ModuleConfig/Serial.tsx +++ b/src/components/PageComponents/ModuleConfig/Serial.tsx @@ -1,4 +1,7 @@ -import type { SerialValidation } from "@app/validation/moduleConfig/serial.ts"; +import { + type SerialValidation, + SerialValidationSchema, +} from "@app/validation/moduleConfig/serial.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Serial = () => { return ( onSubmit={onSubmit} + validationSchema={SerialValidationSchema} + formId="ModuleConfig_SerialConfig" defaultValues={moduleConfig.serial} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/StoreForward.tsx b/src/components/PageComponents/ModuleConfig/StoreForward.tsx index 6118dad7..02b5d630 100644 --- a/src/components/PageComponents/ModuleConfig/StoreForward.tsx +++ b/src/components/PageComponents/ModuleConfig/StoreForward.tsx @@ -1,4 +1,7 @@ -import type { StoreForwardValidation } from "@app/validation/moduleConfig/storeForward.ts"; +import { + type StoreForwardValidation, + StoreForwardValidationSchema, +} from "@app/validation/moduleConfig/storeForward.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const StoreForward = () => { return ( onSubmit={onSubmit} + validationSchema={StoreForwardValidationSchema} + formId="ModuleConfig_StoreForwardConfig" defaultValues={moduleConfig.storeForward} fieldGroups={[ { diff --git a/src/components/PageComponents/ModuleConfig/Telemetry.tsx b/src/components/PageComponents/ModuleConfig/Telemetry.tsx index 5515a8d3..d296fe71 100644 --- a/src/components/PageComponents/ModuleConfig/Telemetry.tsx +++ b/src/components/PageComponents/ModuleConfig/Telemetry.tsx @@ -1,4 +1,7 @@ -import type { TelemetryValidation } from "@app/validation/moduleConfig/telemetry.ts"; +import { + type TelemetryValidation, + TelemetryValidationSchema, +} from "@app/validation/moduleConfig/telemetry.ts"; import { create } from "@bufbuild/protobuf"; import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; @@ -23,6 +26,8 @@ export const Telemetry = () => { return ( onSubmit={onSubmit} + validationSchema={TelemetryValidationSchema} + formId="ModuleConfig_TelemetryConfig" defaultValues={moduleConfig.telemetry} fieldGroups={[ { diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index 213ff10f..2cb22b16 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -1,5 +1,4 @@ -import * as React from "react"; - +import { useEffect, useRef } from "react"; import { Button, type ButtonVariant } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; import { @@ -27,9 +26,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { actionButtons: ActionButton[]; bits?: { text: string; value: string; key: string }[]; selectChange: (event: string) => void; - inputChange: ( - event: React.ChangeEventHandler | undefined, - ) => void; + inputChange: React.ChangeEventHandler; showPasswordToggle?: boolean; showCopyButton?: boolean; disabled?: boolean; @@ -57,10 +54,10 @@ const Generator = ( ...props }: GeneratorProps, ) => { - const inputRef = React.useRef(null); + const inputRef = useRef(null); // Invokes onChange event on the input element when the value changes from the parent component - React.useEffect(() => { + useEffect(() => { if (!inputRef.current) return; const setValue = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, diff --git a/src/core/utils/dotPath.test.ts b/src/core/utils/dotPath.test.ts new file mode 100644 index 00000000..801a6637 --- /dev/null +++ b/src/core/utils/dotPath.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { dotPaths } from "./dotPath.ts"; + +describe("dotPaths", () => { + it("returns flat keys for a simple object", () => { + const obj = { a: 1, b: 2, c: 3 }; + expect(dotPaths(obj)).toEqual(["a", "b", "c"]); + }); + + it("returns dot notation keys for nested objects", () => { + const obj = { a: { b: { c: 1 } }, d: 2 }; + expect(dotPaths(obj)).toEqual(["a.b.c", "d"]); + }); + + it("handles arrays at the root", () => { + const arr = [{ x: 1 }, { y: 2 }]; + expect(dotPaths(arr)).toEqual(["0.x", "1.y"]); + }); + + it("handles arrays nested in objects", () => { + const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 }; + expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]); + }); + + it("handles objects nested in arrays", () => { + const arr = [{ a: { b: 1 } }, { c: 2 }]; + expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]); + }); + + it("handles primitive values in arrays", () => { + const arr = [1, { a: 2 }, 3]; + expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]); + }); + + it("handles empty objects and arrays", () => { + expect(dotPaths({})).toEqual([]); + expect(dotPaths([])).toEqual([]); + }); + + it("handles mixed nested structures", () => { + const obj = { + a: [ + { b: 1, c: [2, 3] }, + { d: { e: 4 } }, + ], + f: 5, + }; + expect(dotPaths(obj)).toEqual([ + "a.0.b", + "a.0.c.0", + "a.0.c.1", + "a.1.d.e", + "f", + ]); + }); + + it("handles prefix argument", () => { + const obj = { a: { b: 1 } }; + expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]); + }); + + it("skips null and undefined values", () => { + const obj = { a: null, b: undefined, c: { d: 1 } }; + expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]); + }); +}); diff --git a/src/core/utils/dotPath.ts b/src/core/utils/dotPath.ts new file mode 100644 index 00000000..85035500 --- /dev/null +++ b/src/core/utils/dotPath.ts @@ -0,0 +1,19 @@ +export type DotPath = { [key: string]: unknown } | unknown[]; + +export const dotPaths = ( + obj: T, + prefix = "", +): string[] => { + if (Array.isArray(obj)) { + return obj.flatMap((v, i) => + v && typeof v === "object" + ? dotPaths(v as DotPath, `${prefix}${i}.`) + : [`${prefix}${i}`] + ); + } + return Object.entries(obj).flatMap(([k, v]) => + v && typeof v === "object" + ? dotPaths(v as DotPath, `${prefix}${k}.`) + : [`${prefix}${k}`] + ); +}; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index b54b8f88..efb44b14 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -69,5 +69,34 @@ }, "nodeUnknownPrefix": "!", "unset": "UNSET", - "fallbackName": "Meshtastic {{last4}}" + "fallbackName": "Meshtastic {{last4}}", + "formValidation": { + "tooBig": { + "string": "Too long, expected less than or equal to {{maximum}} characters.", + "number": "Too big, expected a number smaller than or equal to {{maximum}}.", + "bytes": "Too big, expected less than or equal to {{params.maximum}} bytes." + }, + "tooSmall": { + "string": "Too short, expected more than or equal to {{minimum}} characters.", + "number": "Too small, expected a number larger than or equal to {{minimum}}." + }, + "invalidFormat": { + "ipv4": "Invalid format, expected an IPv4 address.", + "key": "Invalid format, expected a Base64 encoded pre-shared key (PSK)." + }, + "invalidType": { + "number": "Invalid type, expected a number." + }, + "pskLength": { + "0bit": "Key is required to be empty.", + "8bit": "Key is required to be an 8 bit pre-shared key (PSK).", + "128bit": "Key is required to be a 128 bit pre-shared key (PSK).", + "256bit": "Key is required to be a 256 bit pre-shared key (PSK)." + }, + "required": { + "generic": "This field is required.", + "managed": "At least one admin key is requred if the node is managed.", + "key": "Key is required." + } + } } diff --git a/src/i18n/locales/en/deviceConfig.json b/src/i18n/locales/en/deviceConfig.json index 0fc9314e..193c5494 100644 --- a/src/i18n/locales/en/deviceConfig.json +++ b/src/i18n/locales/en/deviceConfig.json @@ -68,11 +68,6 @@ "pin": { "description": "Pin to use when pairing", "label": "Pin" - }, - "validation": { - "pinCannotStartWithZero": "Bluetooth Pin cannot start with 0", - "pinMustBeSixDigits": "Pin must be 6 digits", - "pinRequired": "Bluetooth Pin is required" } }, "display": { @@ -428,15 +423,6 @@ "loggingSettings": { "description": "Settings for Logging", "label": "Logging Settings" - }, - "validation": { - "adminKeyMustBe256BitPsk": "Admin Key is required to be a 256 bit pre-shared key (PSK)", - "adminKeyRequiredWhenManaged": "At least one admin key is requred if the node is managed.", - "enterValid256BitPsk": "Please enter a valid 256 bit PSK", - "invalidAdminKeyFormat": "Invalid Admin Key format", - "invalidPrivateKeyFormat": "Invalid Private Key format", - "privateKeyMustBe256BitPsk": "Private Key is required to be a 256 bit pre-shared key (PSK)", - "privateKeyRequired": "Private Key is required" } } } diff --git a/src/i18n/locales/en/moduleConfig.json b/src/i18n/locales/en/moduleConfig.json index dedf5654..bca3468e 100644 --- a/src/i18n/locales/en/moduleConfig.json +++ b/src/i18n/locales/en/moduleConfig.json @@ -144,9 +144,9 @@ "label": "Monitor Pin", "description": "The GPIO pin to monitor for state changes" }, - "detectionTriggeredHigh": { - "label": "Detection Triggered High", - "description": "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)" + "detectionTriggerType": { + "label": "Detection Triggered Type", + "description": "The type of trigger event to be used" }, "usePullup": { "label": "Use Pullup", diff --git a/src/validation/channel.test.ts b/src/validation/channel.test.ts new file mode 100644 index 00000000..74ce1146 --- /dev/null +++ b/src/validation/channel.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { makeChannelSchema } from "./channel.ts"; +import { fromByteArray } from "base64-js"; + +const mockRole = 0; + +function makeBase64OfLength(len: number): string { + return fromByteArray(new Uint8Array(len)); +} + +describe("makeChannelSchema", () => { + const allowedBytes = 16; + const schema = makeChannelSchema(allowedBytes); + + const validBase64 = makeBase64OfLength(allowedBytes); + + const validSettings = { + channelNum: 3, + psk: validBase64, + name: "TestName", + id: 3, + uplinkEnabled: true, + downlinkEnabled: false, + moduleSettings: { positionPrecision: 10 }, + }; + + it("accepts valid channel object", () => { + const result = schema.safeParse({ + index: 0, + settings: validSettings, + role: mockRole, + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid base64 psk", () => { + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, psk: "not_base64!" }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("psk") + ), + ).toBe(true); + } + }); + + it("rejects psk of wrong length", () => { + const wrongLength = makeBase64OfLength(8); + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, psk: wrongLength }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("psk") + ), + ).toBe(true); + } + }); + + it("rejects name longer than 12 bytes", () => { + const longName = "a".repeat(13); + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, name: longName }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("name") + ), + ).toBe(true); + } + }); + + it("rejects channelNum out of range", () => { + const result = schema.safeParse({ + index: 0, + settings: { ...validSettings, channelNum: 10 }, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.path.includes("settings") && i.path.includes("channelNum") + ), + ).toBe(true); + } + }); + + it("rejects missing required fields", () => { + const result = schema.safeParse({ + index: 0, + settings: {}, + role: mockRole, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + + it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => { + for (const val of [0, 10, 15, 19, 32]) { + const result = schema.safeParse({ + index: 0, + settings: { + ...validSettings, + moduleSettings: { positionPrecision: val }, + }, + role: mockRole, + }); + expect(result.success).toBe(true); + } + }); + + it("rejects moduleSettings.positionPrecision out of range", () => { + for (const val of [9, 20, 31, 33]) { + const result = schema.safeParse({ + index: 0, + settings: { + ...validSettings, + moduleSettings: { positionPrecision: val }, + }, + role: mockRole, + }); + expect(result.success).toBe(false); + } + }); +}); diff --git a/src/validation/channel.ts b/src/validation/channel.ts index 4f4d43bb..37fe50f5 100644 --- a/src/validation/channel.ts +++ b/src/validation/channel.ts @@ -1,51 +1,38 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { - IsBoolean, - IsEnum, - IsInt, - IsNumber, - IsString, - Length, -} from "class-validator"; - -export class ChannelValidation - implements Omit { - @IsNumber() - index: number; - - settings: Channel_SettingsValidation; - - @IsEnum(Protobuf.Channel.Channel_Role) - role: Protobuf.Channel.Channel_Role; -} - -export class Channel_SettingsValidation - implements Omit { - @IsNumber() - channelNum: number; - - @IsString() - psk: string; - - @Length(0, 11) - name: string; - - @IsInt() - id: number; - - @IsBoolean() - uplinkEnabled: boolean; - - @IsBoolean() - downlinkEnabled: boolean; - - @IsBoolean() - positionEnabled: boolean; - - @IsBoolean() - preciseLocation: boolean; - - @IsInt() - positionPrecision: number; +import { makePskHelpers } from "./pskSchema.ts"; +import { validateMaxByteLength } from "@core/utils/string.ts"; + +const RoleEnum = z.enum(Protobuf.Channel.Channel_Role); + +const moduleSettingsSchema = z.object({ + positionPrecision: z.union([ + z.literal(0), + z.coerce.number().int().min(10).max(19), + z.literal(32), + ]), +}); + +export function makeChannelSchema(allowedBytes: number) { + const { stringSchema } = makePskHelpers([allowedBytes]); + + const ChannelSettingsSchema = z.object({ + channelNum: z.coerce.number().int().min(0).max(7), + psk: stringSchema(false), + name: z.string() + .refine( + (s) => validateMaxByteLength(s, 12).isValid, + { message: "formValidation.tooBig.bytes", params: { maximum: 12 } }, + ), + id: z.coerce.number().int(), + uplinkEnabled: z.boolean(), + downlinkEnabled: z.boolean(), + moduleSettings: moduleSettingsSchema, + }); + + return z.object({ + index: z.coerce.number(), + settings: ChannelSettingsSchema, + role: RoleEnum, + }); } diff --git a/src/validation/config/bluetooth.ts b/src/validation/config/bluetooth.ts index 67492240..1c4ce359 100644 --- a/src/validation/config/bluetooth.ts +++ b/src/validation/config/bluetooth.ts @@ -1,18 +1,14 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class BluetoothValidation implements - Omit< - Protobuf.Config.Config_BluetoothConfig, - keyof Message | "deviceLoggingEnabled" - > { - @IsBoolean() - enabled: boolean; +const PairingModeEnum = z.enum( + Protobuf.Config.Config_BluetoothConfig_PairingMode, +); - @IsEnum(Protobuf.Config.Config_BluetoothConfig_PairingMode) - mode: Protobuf.Config.Config_BluetoothConfig_PairingMode; +export const BluetoothValidationSchema = z.object({ + enabled: z.boolean(), + mode: PairingModeEnum, + fixedPin: z.coerce.number().int().min(100000).max(999999), +}); - @IsInt() - fixedPin: number; -} +export type BluetoothValidation = z.infer; diff --git a/src/validation/config/device.ts b/src/validation/config/device.ts index 0858e6a7..21ce6d74 100644 --- a/src/validation/config/device.ts +++ b/src/validation/config/device.ts @@ -1,42 +1,25 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator"; -export class DeviceValidation - implements Omit { - @IsEnum(Protobuf.Config.Config_DeviceConfig_Role) - role: Protobuf.Config.Config_DeviceConfig_Role; - - @IsBoolean() - serialEnabled: boolean; - - @IsBoolean() - debugLogEnabled: boolean; - - @IsInt() - buttonGpio: number; - - @IsInt() - buzzerGpio: number; - - @IsEnum(Protobuf.Config.Config_DeviceConfig_RebroadcastMode) - rebroadcastMode: Protobuf.Config.Config_DeviceConfig_RebroadcastMode; - - @IsInt() - nodeInfoBroadcastSecs: number; - - @IsBoolean() - doubleTapAsButtonPress: boolean; - - @IsBoolean() - isManaged: boolean; - - @IsBoolean() - disableTripleClick: boolean; - - @IsBoolean() - ledHeartbeatDisabled: boolean; - - @IsString() - tzdef: string; -} +const RoleEnum = z.enum( + Protobuf.Config.Config_DeviceConfig_Role, +); +const RebroadcastModeEnum = z.enum( + Protobuf.Config.Config_DeviceConfig_RebroadcastMode, +); + +export const DeviceValidationSchema = z.object({ + role: RoleEnum, + serialEnabled: z.boolean(), + buttonGpio: z.coerce.number().int().min(0), + buzzerGpio: z.coerce.number().int().min(0), + rebroadcastMode: RebroadcastModeEnum, + nodeInfoBroadcastSecs: z.coerce.number().int().min(0), + doubleTapAsButtonPress: z.boolean(), + isManaged: z.boolean(), + disableTripleClick: z.boolean(), + ledHeartbeatDisabled: z.boolean(), + tzdef: z.string().max(65), +}); + +export type DeviceValidation = z.infer; diff --git a/src/validation/config/display.ts b/src/validation/config/display.ts index 6f38164b..c3fea8ee 100644 --- a/src/validation/config/display.ts +++ b/src/validation/config/display.ts @@ -1,39 +1,35 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class DisplayValidation - implements Omit { - @IsInt() - screenOnSecs: number; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat) - gpsFormat: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat; - - @IsInt() - autoScreenCarouselSecs: number; - - @IsBoolean() - compassNorthTop: boolean; - - @IsBoolean() - flipScreen: boolean; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayUnits) - units: Protobuf.Config.Config_DisplayConfig_DisplayUnits; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_OledType) - oled: Protobuf.Config.Config_DisplayConfig_OledType; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_DisplayMode) - displaymode: Protobuf.Config.Config_DisplayConfig_DisplayMode; - - @IsBoolean() - headingBold: boolean; - - @IsBoolean() - wakeOnTapOrMotion: boolean; - - @IsEnum(Protobuf.Config.Config_DisplayConfig_CompassOrientation) - compassOrientation: Protobuf.Config.Config_DisplayConfig_CompassOrientation; -} +const GpsCoordinateEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat, +); +const DisplayUnitsEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_DisplayUnits, +); +const OledTypeEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_OledType, +); +const DisplayModeEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_DisplayMode, +); +const CompassOrientationEnum = z.enum( + Protobuf.Config.Config_DisplayConfig_CompassOrientation, +); + +export const DisplayValidationSchema = z.object({ + screenOnSecs: z.coerce.number().int().min(0), + gpsFormat: GpsCoordinateEnum, + autoScreenCarouselSecs: z.coerce.number().int().min(0), + compassNorthTop: z.boolean(), + flipScreen: z.boolean(), + units: DisplayUnitsEnum, + oled: OledTypeEnum, + displaymode: DisplayModeEnum, + headingBold: z.boolean(), + wakeOnTapOrMotion: z.boolean(), + compassOrientation: CompassOrientationEnum, + use12hClock: z.boolean(), +}); + +export type DisplayValidation = z.infer; diff --git a/src/validation/config/lora.ts b/src/validation/config/lora.ts index ad70b586..7511bccd 100644 --- a/src/validation/config/lora.ts +++ b/src/validation/config/lora.ts @@ -1,65 +1,31 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator"; -export class LoRaValidation - implements - Omit { - @IsBoolean() - usePreset: boolean; - - @IsEnum(Protobuf.Config.Config_LoRaConfig_ModemPreset) - modemPreset: Protobuf.Config.Config_LoRaConfig_ModemPreset; - - @IsInt() - bandwidth: number; - - @IsInt() - // @Min(7) - @Max(12) - spreadFactor: number; - - @IsInt() - @Min(0) - @Max(10) - codingRate: number; - - @IsInt() - frequencyOffset: number; - - @IsEnum(Protobuf.Config.Config_LoRaConfig_RegionCode) - region: Protobuf.Config.Config_LoRaConfig_RegionCode; - - @IsInt() - @Min(1) - @Max(7) - hopLimit: number; - - @IsBoolean() - txEnabled: boolean; - - @IsInt() - @Min(0) - txPower: number; - - @IsInt() - channelNum: number; - - @IsBoolean() - overrideDutyCycle: boolean; - - @IsBoolean() - sx126xRxBoostedGain: boolean; - - @IsInt() - overrideFrequency: number; - - @IsArray() - ignoreIncoming: number[]; - - @IsBoolean() - ignoreMqtt: boolean; - - @IsBoolean() - configOkToMqtt: boolean; -} +const ModemPresetEnum = z.enum( + Protobuf.Config.Config_LoRaConfig_ModemPreset, +); +const RegionCodeEnum = z.enum( + Protobuf.Config.Config_LoRaConfig_RegionCode, +); + +export const LoRaValidationSchema = z.object({ + usePreset: z.boolean(), + modemPreset: ModemPresetEnum, + bandwidth: z.coerce.number().int(), + spreadFactor: z.coerce.number().int().max(12), + codingRate: z.coerce.number().int().min(0).max(10), + frequencyOffset: z.coerce.number().int(), + region: RegionCodeEnum, + hopLimit: z.coerce.number().int().min(0).max(7), + txEnabled: z.boolean(), + txPower: z.coerce.number().int().min(0), + channelNum: z.coerce.number().int(), + overrideDutyCycle: z.boolean(), + sx126xRxBoostedGain: z.boolean(), + overrideFrequency: z.coerce.number().int(), + ignoreIncoming: z.coerce.number().array(), + ignoreMqtt: z.boolean(), + configOkToMqtt: z.boolean(), +}); + +export type LoRaValidation = z.infer; diff --git a/src/validation/config/network.ts b/src/validation/config/network.ts index e58fb94d..26ba9c36 100644 --- a/src/validation/config/network.ts +++ b/src/validation/config/network.ts @@ -1,30 +1,30 @@ -import { z } from "zod"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -const AddressModeEnum = z.nativeEnum( +const AddressModeEnum = z.enum( Protobuf.Config.Config_NetworkConfig_AddressMode, ); -const ProtocolFlagsEnum = z.nativeEnum( +const ProtocolFlagsEnum = z.enum( Protobuf.Config.Config_NetworkConfig_ProtocolFlags, ); export const NetworkValidationIpV4ConfigSchema = z.object({ - ip: z.string().ip(), - gateway: z.string().ip(), - subnet: z.string().ip(), - dns: z.string().ip(), + ip: z.ipv4(), + gateway: z.ipv4(), + subnet: z.ipv4(), + dns: z.ipv4(), }); export const NetworkValidationSchema = z.object({ wifiEnabled: z.boolean(), - wifiSsid: z.string().min(0).max(33).optional(), - wifiPsk: z.string().min(0).max(64).optional(), - ntpServer: z.string().min(2).max(30), + wifiSsid: z.string().max(33), + wifiPsk: z.string().max(64), + ntpServer: z.string().min(2).max(33), ethEnabled: z.boolean(), addressMode: AddressModeEnum, - ipv4Config: NetworkValidationIpV4ConfigSchema.optional(), + ipv4Config: NetworkValidationIpV4ConfigSchema, enabledProtocols: ProtocolFlagsEnum, - rsyslogServer: z.string(), + rsyslogServer: z.string().max(33), }); export type NetworkValidation = z.infer; diff --git a/src/validation/config/position.ts b/src/validation/config/position.ts index 84e67603..1174d410 100644 --- a/src/validation/config/position.ts +++ b/src/validation/config/position.ts @@ -1,44 +1,22 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"]; - -export class PositionValidation implements - Omit< - Protobuf.Config.Config_PositionConfig, - keyof Message | (typeof DeprecatedPositionValidationFields)[number] - > { - @IsInt() - positionBroadcastSecs: number; - - @IsBoolean() - positionBroadcastSmartEnabled: boolean; - - @IsBoolean() - fixedPosition: boolean; - - @IsInt() - gpsUpdateInterval: number; - - @IsInt() - positionFlags: number; - - @IsInt() - rxGpio: number; - - @IsInt() - txGpio: number; - - @IsInt() - broadcastSmartMinimumDistance: number; - - @IsInt() - broadcastSmartMinimumIntervalSecs: number; - - @IsInt() - gpsEnGpio: number; - - @IsEnum(Protobuf.Config.Config_PositionConfig_GpsMode) - gpsMode: Protobuf.Config.Config_PositionConfig_GpsMode; -} +const GpsModeEnum = z.enum( + Protobuf.Config.Config_PositionConfig_GpsMode, +); + +export const PositionValidationSchema = z.object({ + positionBroadcastSecs: z.coerce.number().int().min(0), + positionBroadcastSmartEnabled: z.boolean(), + fixedPosition: z.boolean(), + gpsUpdateInterval: z.coerce.number().int().min(0), + positionFlags: z.coerce.number().int().min(0), + rxGpio: z.coerce.number().int().min(0), + txGpio: z.coerce.number().int().min(0), + broadcastSmartMinimumDistance: z.coerce.number().int().min(0), + broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0), + gpsEnGpio: z.coerce.number().int().min(0), + gpsMode: GpsModeEnum, +}); + +export type PositionValidation = z.infer; diff --git a/src/validation/config/power.ts b/src/validation/config/power.ts index a83f5f15..4c99fd76 100644 --- a/src/validation/config/power.ts +++ b/src/validation/config/power.ts @@ -1,35 +1,14 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator"; - -export class PowerValidation implements - Omit< - Protobuf.Config.Config_PowerConfig, - keyof Message | "powermonEnables" - > { - @IsBoolean() - isPowerSaving: boolean; - - @IsInt() - onBatteryShutdownAfterSecs: number; - - @IsNumber() - @Min(2) - @Max(4) - adcMultiplierOverride: number; - - @IsInt() - waitBluetoothSecs: number; - - @IsInt() - sdsSecs: number; - - @IsInt() - lsSecs: number; - - @IsInt() - minWakeSecs: number; - - @IsInt() - deviceBatteryInaAddress: number; -} +import { z } from "zod/v4"; + +export const PowerValidationSchema = z.object({ + isPowerSaving: z.boolean(), + onBatteryShutdownAfterSecs: z.coerce.number().int().min(0), + adcMultiplierOverride: z.coerce.number().min(0).max(4), + waitBluetoothSecs: z.coerce.number().int().min(0), + sdsSecs: z.coerce.number().int().min(0), + lsSecs: z.coerce.number().int().min(0), + minWakeSecs: z.coerce.number().int().min(0), + deviceBatteryInaAddress: z.coerce.number().int().min(0), +}); + +export type PowerValidation = z.infer; diff --git a/src/validation/config/security.test.ts b/src/validation/config/security.test.ts new file mode 100644 index 00000000..653baab4 --- /dev/null +++ b/src/validation/config/security.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { fromByteArray } from "base64-js"; +import { ParsedSecuritySchema, RawSecuritySchema } from "./security.ts"; + +function makeBase64OfLength(len: number): string { + return fromByteArray(new Uint8Array(len)); +} + +describe("RawSecuritySchema", () => { + const validKey = makeBase64OfLength(32); + + it("accepts valid security config", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: false, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [validKey, "", ""], + }); + expect(result.success).toBe(true); + }); + + it("rejects if privateKey is invalid", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: false, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: "badkey", + publicKey: validKey, + adminKey: [validKey, "", ""], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.includes("privateKey"))) + .toBe(true); + } + }); + + it("requires at least one adminKey if isManaged", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: true, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: ["", "", ""], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message === "formValidation.adminKeyRequiredWhenManaged" + ), + ).toBe(true); + } + }); + + it("accepts if at least one adminKey is valid when isManaged", () => { + const result = RawSecuritySchema.safeParse({ + isManaged: true, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [validKey, "", ""], + }); + expect(result.success).toBe(true); + }); +}); + +describe("ParsedSecuritySchema", () => { + const validKey = new Uint8Array(32); + + it("accepts valid parsed security config", () => { + const result = ParsedSecuritySchema.safeParse({ + isManaged: false, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [validKey, new Uint8Array(), new Uint8Array()], + }); + console.log(result); + + expect(result.success).toBe(true); + }); + + it("requires at least one adminKey if isManaged", () => { + const result = ParsedSecuritySchema.safeParse({ + isManaged: true, + adminChannelEnabled: true, + debugLogApiEnabled: false, + serialEnabled: true, + privateKey: validKey, + publicKey: validKey, + adminKey: [new Uint8Array(), new Uint8Array(), new Uint8Array()], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((i) => + i.message === "formValidation.adminKeyRequiredWhenManaged" + ), + ).toBe(true); + } + }); +}); diff --git a/src/validation/config/security.ts b/src/validation/config/security.ts index 5d6ed059..2053b085 100644 --- a/src/validation/config/security.ts +++ b/src/validation/config/security.ts @@ -1,36 +1,47 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsString } from "class-validator"; - -export class SecurityValidation implements - Omit< - Protobuf.Config.Config_SecurityConfig, - | keyof Message - | "adminKey" - | "privateKey" - | "publicKey" - > { - @IsBoolean() - adminChannelEnabled: boolean; - - @IsString() - adminKey: [string, string, string]; - - @IsBoolean() - bluetoothLoggingEnabled: boolean; - - @IsBoolean() - debugLogApiEnabled: boolean; - - @IsBoolean() - isManaged: boolean; - - @IsString() - privateKey: string; +import { z, ZodType } from "zod/v4"; +import { makePskHelpers } from "./../pskSchema.ts"; + +const { + stringSchema, + bytesSchema, + isValidKey, +} = makePskHelpers([32]); // 256-bit + +const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged"; + +function makeSecuritySchema( + keyMaker: (optional: boolean) => ZodType, +) { + return z + .object({ + isManaged: z.boolean(), + adminChannelEnabled: z.boolean(), + debugLogApiEnabled: z.boolean(), + serialEnabled: z.boolean(), + + privateKey: keyMaker(false), + publicKey: keyMaker(false), + adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]), + }) + .check((ctx) => { + if (ctx.value.isManaged) { + const hasAdmin = ctx.value.adminKey.some(isValidKey); + if (!hasAdmin) { + for (const path of [["isManaged"], ["adminKey", 0]] as const) { + ctx.issues.push({ + code: "custom", + message: isManagedRequiredMsg, + path: [...path], + input: ctx.value, + }); + } + } + } + }); +} - @IsString() - publicKey: string; +export const RawSecuritySchema = makeSecuritySchema(stringSchema); +export type RawSecurity = z.infer; - @IsBoolean() - serialEnabled: boolean; -} +export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema); +export type ParsedSecurity = z.infer; diff --git a/src/validation/moduleConfig/ambientLighting.ts b/src/validation/moduleConfig/ambientLighting.ts index 93124502..618b7044 100644 --- a/src/validation/moduleConfig/ambientLighting.ts +++ b/src/validation/moduleConfig/ambientLighting.ts @@ -1,24 +1,13 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class AmbientLightingValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig, - keyof Message - > { - @IsBoolean() - ledState: boolean; - - @IsInt() - current: number; - - @IsInt() - red: number; - - @IsInt() - green: number; - - @IsInt() - blue: number; -} +import { z } from "zod/v4"; + +export const AmbientLightingValidationSchema = z.object({ + ledState: z.boolean(), + current: z.coerce.number().int().min(0), + red: z.coerce.number().int().min(0).max(255), + green: z.coerce.number().int().min(0).max(255), + blue: z.coerce.number().int().min(0).max(255), +}); + +export type AmbientLightingValidation = z.infer< + typeof AmbientLightingValidationSchema +>; diff --git a/src/validation/moduleConfig/audio.ts b/src/validation/moduleConfig/audio.ts index b70e3dae..87c5e881 100644 --- a/src/validation/moduleConfig/audio.ts +++ b/src/validation/moduleConfig/audio.ts @@ -1,28 +1,18 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class AudioValidation - implements - Omit { - @IsBoolean() - codec2Enabled: boolean; +const Audio_BaudEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud, +); - @IsInt() - pttPin: number; +export const AudioValidationSchema = z.object({ + codec2Enabled: z.boolean(), + pttPin: z.coerce.number().int().min(0), + bitrate: Audio_BaudEnum, + i2sWs: z.coerce.number().int().min(0), + i2sSd: z.coerce.number().int().min(0), + i2sDin: z.coerce.number().int().min(0), + i2sSck: z.coerce.number().int().min(0), +}); - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud) - bitrate: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud; - - @IsInt() - i2sWs: number; - - @IsInt() - i2sSd: number; - - @IsInt() - i2sDin: number; - - @IsInt() - i2sSck: number; -} +export type AudioValidation = z.infer; diff --git a/src/validation/moduleConfig/cannedMessage.ts b/src/validation/moduleConfig/cannedMessage.ts index 2b70eaf5..0fd7d98e 100644 --- a/src/validation/moduleConfig/cannedMessage.ts +++ b/src/validation/moduleConfig/cannedMessage.ts @@ -1,45 +1,24 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt, Length } from "class-validator"; -export class CannedMessageValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig, - keyof Message - > { - @IsBoolean() - rotary1Enabled: boolean; - - @IsInt() - inputbrokerPinA: number; - - @IsInt() - inputbrokerPinB: number; - - @IsInt() - inputbrokerPinPress: number; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) - inputbrokerEventCw: - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) - inputbrokerEventCcw: - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar) - inputbrokerEventPress: - Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar; - - @IsBoolean() - updown1Enabled: boolean; - - @IsBoolean() - enabled: boolean; - - @Length(2, 30) - allowInputSource: string; - - @IsBoolean() - sendBell: boolean; -} +const InputEventCharEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar, +); + +export const CannedMessageValidationSchema = z.object({ + rotary1Enabled: z.boolean(), + inputbrokerPinA: z.coerce.number().int().min(0), + inputbrokerPinB: z.coerce.number().int().min(0), + inputbrokerPinPress: z.coerce.number().int().min(0), + inputbrokerEventCw: InputEventCharEnum, + inputbrokerEventCcw: InputEventCharEnum, + inputbrokerEventPress: InputEventCharEnum, + updown1Enabled: z.boolean(), + enabled: z.boolean(), + allowInputSource: z.string().max(30), + sendBell: z.boolean(), +}); + +export type CannedMessageValidation = z.infer< + typeof CannedMessageValidationSchema +>; diff --git a/src/validation/moduleConfig/detectionSensor.ts b/src/validation/moduleConfig/detectionSensor.ts index b812df17..39f1f1e3 100644 --- a/src/validation/moduleConfig/detectionSensor.ts +++ b/src/validation/moduleConfig/detectionSensor.ts @@ -1,33 +1,21 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt, Length } from "class-validator"; - -export class DetectionSensorValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig, - keyof Message - > { - @IsBoolean() - enabled: boolean; - - @IsInt() - minimumBroadcastSecs: number; - - @IsInt() - stateBroadcastSecs: number; - - @IsBoolean() - sendBell: boolean; - - @Length(0, 20) - name: string; - - @IsInt() - monitorPin: number; - - @IsBoolean() - detectionTriggeredHigh: boolean; - - @IsBoolean() - usePullup: boolean; -} +import { z } from "zod/v4"; +import { Protobuf } from "@meshtastic/core"; + +const detectionTriggerTypeEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType, +); + +export const DetectionSensorValidationSchema = z.object({ + enabled: z.boolean(), + minimumBroadcastSecs: z.coerce.number().int().min(0), + stateBroadcastSecs: z.coerce.number().int().min(0), + sendBell: z.boolean(), + name: z.string().min(0).max(20), + monitorPin: z.coerce.number().int().min(0), + detectionTriggerType: detectionTriggerTypeEnum, + usePullup: z.boolean(), +}); + +export type DetectionSensorValidation = z.infer< + typeof DetectionSensorValidationSchema +>; diff --git a/src/validation/moduleConfig/externalNotification.ts b/src/validation/moduleConfig/externalNotification.ts index 5b68ffa3..b8769f0e 100644 --- a/src/validation/moduleConfig/externalNotification.ts +++ b/src/validation/moduleConfig/externalNotification.ts @@ -1,54 +1,23 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class ExternalNotificationValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig, - keyof Message - > { - @IsBoolean() - enabled: boolean; - - @IsInt() - outputMs: number; - - @IsInt() - output: number; - - @IsInt() - outputVibra: number; - - @IsInt() - outputBuzzer: number; - - @IsBoolean() - active: boolean; - - @IsBoolean() - alertMessage: boolean; - - @IsBoolean() - alertMessageVibra: boolean; - - @IsBoolean() - alertMessageBuzzer: boolean; - - @IsBoolean() - alertBell: boolean; - - @IsBoolean() - alertBellVibra: boolean; - - @IsBoolean() - alertBellBuzzer: boolean; - - @IsBoolean() - usePwm: boolean; - - @IsInt() - nagTimeout: number; - - @IsBoolean() - useI2sAsBuzzer: boolean; -} +import { z } from "zod/v4"; + +export const ExternalNotificationValidationSchema = z.object({ + enabled: z.boolean(), + outputMs: z.coerce.number().int().min(0), + output: z.coerce.number().int().min(0), + outputVibra: z.coerce.number().int().min(0), + outputBuzzer: z.coerce.number().int().min(0), + active: z.boolean(), + alertMessage: z.boolean(), + alertMessageVibra: z.boolean(), + alertMessageBuzzer: z.boolean(), + alertBell: z.boolean(), + alertBellVibra: z.boolean(), + alertBellBuzzer: z.boolean(), + usePwm: z.boolean(), + nagTimeout: z.coerce.number().int().min(0), + useI2sAsBuzzer: z.boolean(), +}); + +export type ExternalNotificationValidation = z.infer< + typeof ExternalNotificationValidationSchema +>; diff --git a/src/validation/moduleConfig/mqtt.ts b/src/validation/moduleConfig/mqtt.ts index 44312c8e..638f076f 100644 --- a/src/validation/moduleConfig/mqtt.ts +++ b/src/validation/moduleConfig/mqtt.ts @@ -1,59 +1,22 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { - IsBoolean, - IsNumber, - IsOptional, - IsString, - Length, -} from "class-validator"; - -export class MqttValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_MQTTConfig, - keyof Message | "mapReportSettings" - > { - @IsBoolean() - enabled: boolean; - - @Length(0, 30) - address: string; - - @Length(0, 30) - username: string; - - @Length(0, 30) - password: string; - - @IsBoolean() - encryptionEnabled: boolean; - - @IsBoolean() - jsonEnabled: boolean; - - @IsBoolean() - tlsEnabled: boolean; - - @IsString() - root: string; - - @IsBoolean() - proxyToClientEnabled: boolean; - - @IsBoolean() - mapReportingEnabled: boolean; - - mapReportSettings: MqttValidationMapReportSettings; -} - -export class MqttValidationMapReportSettings - implements - Omit { - @IsNumber() - @IsOptional() - publishIntervalSecs: number; - - @IsNumber() - @IsOptional() - positionPrecision: number; -} +import { z } from "zod/v4"; + +export const MqttValidationMapReportSettingsSchema = z.object({ + publishIntervalSecs: z.number().optional(), + positionPrecision: z.number().optional(), +}); + +export const MqttValidationSchema = z.object({ + enabled: z.boolean(), + address: z.string().min(0).max(30), + username: z.string().min(0).max(30), + password: z.string().min(0).max(30), + encryptionEnabled: z.boolean(), + jsonEnabled: z.boolean(), + tlsEnabled: z.boolean(), + root: z.string(), + proxyToClientEnabled: z.boolean(), + mapReportingEnabled: z.boolean(), + mapReportSettings: MqttValidationMapReportSettingsSchema, +}); + +export type MqttValidation = z.infer; diff --git a/src/validation/moduleConfig/neighborInfo.ts b/src/validation/moduleConfig/neighborInfo.ts index 513b8d7c..bd26f41b 100644 --- a/src/validation/moduleConfig/neighborInfo.ts +++ b/src/validation/moduleConfig/neighborInfo.ts @@ -1,13 +1,10 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; +import { z } from "zod/v4"; -export class NeighborInfoValidation - implements - Omit { - @IsBoolean() - enabled: boolean; +export const NeighborInfoValidationSchema = z.object({ + enabled: z.boolean(), + updateInterval: z.coerce.number().int().min(0), +}); - @IsInt() - updateInterval: number; -} +export type NeighborInfoValidation = z.infer< + typeof NeighborInfoValidationSchema +>; diff --git a/src/validation/moduleConfig/paxcounter.ts b/src/validation/moduleConfig/paxcounter.ts index 6b3eeb9b..61359065 100644 --- a/src/validation/moduleConfig/paxcounter.ts +++ b/src/validation/moduleConfig/paxcounter.ts @@ -1,19 +1,10 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; +import { z } from "zod/v4"; -export class PaxcounterValidation - implements - Omit { - @IsBoolean() - enabled: boolean; +export const PaxcounterValidationSchema = z.object({ + enabled: z.boolean(), + paxcounterUpdateInterval: z.coerce.number().int().min(0), + bleThreshold: z.coerce.number().int(), + wifiThreshold: z.coerce.number().int(), +}); - @IsInt() - paxcounterUpdateInterval: number; - - @IsInt() - bleThreshold: number; - - @IsInt() - wifiThreshold: number; -} +export type PaxcounterValidation = z.infer; diff --git a/src/validation/moduleConfig/rangeTest.ts b/src/validation/moduleConfig/rangeTest.ts index 5debf256..1dbf54c3 100644 --- a/src/validation/moduleConfig/rangeTest.ts +++ b/src/validation/moduleConfig/rangeTest.ts @@ -1,16 +1,9 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; +import { z } from "zod/v4"; -export class RangeTestValidation - implements - Omit { - @IsBoolean() - enabled: boolean; +export const RangeTestValidationSchema = z.object({ + enabled: z.boolean(), + sender: z.coerce.number().int().min(0), + save: z.boolean(), +}); - @IsInt() - sender: number; - - @IsBoolean() - save: boolean; -} +export type RangeTestValidation = z.infer; diff --git a/src/validation/moduleConfig/serial.ts b/src/validation/moduleConfig/serial.ts index c814a71b..95966105 100644 --- a/src/validation/moduleConfig/serial.ts +++ b/src/validation/moduleConfig/serial.ts @@ -1,31 +1,22 @@ -import type { Message } from "@bufbuild/protobuf"; +import { z } from "zod/v4"; import { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsEnum, IsInt } from "class-validator"; -export class SerialValidation - implements - Omit { - @IsBoolean() - enabled: boolean; - - @IsBoolean() - echo: boolean; - - @IsInt() - rxd: number; - - @IsInt() - txd: number; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud) - baud: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud; - - @IsInt() - timeout: number; - - @IsEnum(Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode) - mode: Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode; - - @IsBoolean() - overrideConsoleSerialPort: boolean; -} +const Serial_BaudEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud, +); +const Serial_ModeEnum = z.enum( + Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode, +); + +export const SerialValidationSchema = z.object({ + enabled: z.boolean(), + echo: z.boolean(), + rxd: z.coerce.number().int().min(0), + txd: z.coerce.number().int().min(0), + baud: Serial_BaudEnum, + timeout: z.coerce.number().int().min(0), + mode: Serial_ModeEnum, + overrideConsoleSerialPort: z.boolean(), +}); + +export type SerialValidation = z.infer; diff --git a/src/validation/moduleConfig/storeForward.ts b/src/validation/moduleConfig/storeForward.ts index e539dd4b..6c2257d2 100644 --- a/src/validation/moduleConfig/storeForward.ts +++ b/src/validation/moduleConfig/storeForward.ts @@ -1,24 +1,13 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class StoreForwardValidation implements - Omit< - Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, - keyof Message | "isServer" - > { - @IsBoolean() - enabled: boolean; - - @IsBoolean() - heartbeat: boolean; - - @IsInt() - records: number; - - @IsInt() - historyReturnMax: number; - - @IsInt() - historyReturnWindow: number; -} +import { z } from "zod/v4"; + +export const StoreForwardValidationSchema = z.object({ + enabled: z.boolean(), + heartbeat: z.boolean(), + records: z.coerce.number().int().min(0), + historyReturnMax: z.coerce.number().int().min(0), + historyReturnWindow: z.coerce.number().int().min(0), +}); + +export type StoreForwardValidation = z.infer< + typeof StoreForwardValidationSchema +>; diff --git a/src/validation/moduleConfig/telemetry.ts b/src/validation/moduleConfig/telemetry.ts index 921fb04a..8f6605a2 100644 --- a/src/validation/moduleConfig/telemetry.ts +++ b/src/validation/moduleConfig/telemetry.ts @@ -1,37 +1,18 @@ -import type { Message } from "@bufbuild/protobuf"; -import type { Protobuf } from "@meshtastic/core"; -import { IsBoolean, IsInt } from "class-validator"; - -export class TelemetryValidation - implements - Omit { - @IsInt() - deviceUpdateInterval: number; - - @IsInt() - environmentUpdateInterval: number; - - @IsBoolean() - environmentMeasurementEnabled: boolean; - - @IsBoolean() - environmentScreenEnabled: boolean; - - @IsBoolean() - environmentDisplayFahrenheit: boolean; - - @IsBoolean() - airQualityEnabled: boolean; - - @IsInt() - airQualityInterval: number; - - @IsBoolean() - powerMeasurementEnabled: boolean; - - @IsInt() - powerUpdateInterval: number; - - @IsBoolean() - powerScreenEnabled: boolean; -} +import { z } from "zod/v4"; + +export const TelemetryValidationSchema = z.object({ + deviceUpdateInterval: z.coerce.number().int().min(0), + environmentUpdateInterval: z.coerce.number().int().min(0), + environmentMeasurementEnabled: z.boolean(), + environmentScreenEnabled: z.boolean(), + environmentDisplayFahrenheit: z.boolean(), + airQualityEnabled: z.boolean(), + airQualityInterval: z.coerce.number().int().min(0), + powerMeasurementEnabled: z.boolean(), + powerUpdateInterval: z.coerce.number().int().min(0), + powerScreenEnabled: z.boolean(), +}); + +export type TelemetryValidation = z.infer< + typeof TelemetryValidationSchema +>; diff --git a/src/validation/pskSchema.test.ts b/src/validation/pskSchema.test.ts new file mode 100644 index 00000000..20ec58a8 --- /dev/null +++ b/src/validation/pskSchema.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { makePskHelpers } from "./pskSchema.ts"; +import { fromByteArray } from "base64-js"; + +function makeBase64OfLength(len: number): string { + return fromByteArray(new Uint8Array(len)); +} + +describe("stringSchema", () => { + it("accepts valid base64 string of allowed length", () => { + const { stringSchema } = makePskHelpers([16]); + const valid = makeBase64OfLength(16); + expect(() => stringSchema().parse(valid)).not.toThrow(); + }); + + it("rejects base64 string of disallowed length", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const invalid = makeBase64OfLength(8); + const result = stringSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + + it("rejects invalid base64 string", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const result = stringSchema().safeParse("not_base64!"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.format); + } + }); + + it("rejects empty string if not optional and 0 not allowed", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const result = stringSchema().safeParse(""); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.required); + } + }); + + it("accepts empty string if 0 is allowed", () => { + const { stringSchema } = makePskHelpers([0]); + const result = stringSchema().safeParse(""); + expect(result.success).toBe(true); + }); + + it("accepts empty string if optional=true", () => { + const { stringSchema } = makePskHelpers([16]); + const result = stringSchema(true).safeParse(""); + expect(result.success).toBe(true); + }); + + it("accepts all allowed lengths", () => { + const { stringSchema } = makePskHelpers([8, 16, 32]); + for (const len of [8, 16, 32]) { + const valid = makeBase64OfLength(len); + const result = stringSchema().safeParse(valid); + expect(result.success).toBe(true); + } + }); + + it("accepts valid base64 string as optional when optional=true", () => { + const { stringSchema } = makePskHelpers([16]); + const valid = makeBase64OfLength(16); + const result = stringSchema(true).safeParse(valid); + expect(result.success).toBe(true); + }); + + it("rejects base64 string with correct length but extra padding", () => { + const { stringSchema, msgs } = makePskHelpers([16]); + const valid = makeBase64OfLength(16) + "=="; + const result = stringSchema().safeParse(valid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.format); + } + }); + + it("accepts empty string if allowedByteLengths includes 0 and optional=false", () => { + const { stringSchema } = makePskHelpers([0, 16]); + const result = stringSchema(false).safeParse(""); + expect(result.success).toBe(true); + }); + + it("rejects base64 string with valid format but not in allowedByteLengths", () => { + const { stringSchema, msgs } = makePskHelpers([8, 32]); + const invalid = makeBase64OfLength(16); + const result = stringSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + + describe("bytesSchema", () => { + it("accepts valid byte array of allowed length", () => { + const { bytesSchema } = makePskHelpers([16]); + const valid = new Uint8Array(16); + expect(() => bytesSchema().parse(valid)).not.toThrow(); + }); + + it("rejects byte array of disallowed length", () => { + const { bytesSchema, msgs } = makePskHelpers([16]); + const invalid = new Uint8Array(8); + const result = bytesSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + + it("rejects non-Uint8Array input", () => { + const { bytesSchema } = makePskHelpers([16]); + const result = bytesSchema().safeParse([1, 2, 3]); + expect(result.success).toBe(false); + }); + + it("rejects empty array if not optional and 0 not allowed", () => { + const { bytesSchema, msgs } = makePskHelpers([16]); + const result = bytesSchema().safeParse(new Uint8Array(0)); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.required); + } + }); + + it("accepts empty array if 0 is allowed", () => { + const { bytesSchema } = makePskHelpers([0]); + const result = bytesSchema().safeParse(new Uint8Array(0)); + expect(result.success).toBe(true); + }); + + it("accepts empty array if optional=true", () => { + const { bytesSchema } = makePskHelpers([16]); + const result = bytesSchema(true).safeParse(new Uint8Array(0)); + expect(result.success).toBe(true); + }); + + it("accepts all allowed lengths", () => { + const { bytesSchema } = makePskHelpers([8, 16, 32]); + for (const len of [8, 16, 32]) { + const valid = new Uint8Array(len); + const result = bytesSchema().safeParse(valid); + expect(result.success).toBe(true); + } + }); + + it("accepts valid byte array as optional when optional=true", () => { + const { bytesSchema } = makePskHelpers([16]); + const valid = new Uint8Array(16); + const result = bytesSchema(true).safeParse(valid); + expect(result.success).toBe(true); + }); + + it("accepts empty array if allowedByteLengths includes 0 and optional=false", () => { + const { bytesSchema } = makePskHelpers([0, 16]); + const result = bytesSchema(false).safeParse(new Uint8Array(0)); + expect(result.success).toBe(true); + }); + + it("rejects byte array with valid format but not in allowedByteLengths", () => { + const { bytesSchema, msgs } = makePskHelpers([8, 32]); + const invalid = new Uint8Array(16); + const result = bytesSchema().safeParse(invalid); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe(msgs.length); + } + }); + }); +}); diff --git a/src/validation/pskSchema.ts b/src/validation/pskSchema.ts new file mode 100644 index 00000000..ee09b56d --- /dev/null +++ b/src/validation/pskSchema.ts @@ -0,0 +1,70 @@ +import { z, ZodType } from "zod/v4"; +import { toByteArray } from "base64-js"; + +export function makePskHelpers( + allowedByteLengths: readonly number[], +) { + const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | "); + const msgs = { + format: "formValidation.invalidFormat.key", + required: "formValidation.required.key", + length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`, + } as const; + + function tryParse(str: string): Uint8Array | null { + try { + return toByteArray(str); + } catch { + return null; + } + } + + function isValidString(str: string): boolean { + const arr = tryParse(str); + return arr !== null && + allowedByteLengths.includes(arr.byteLength); + } + + function isValidKey(v: unknown): boolean { + if (typeof v === "string") return isValidString(v); + if (v instanceof Uint8Array) { + return allowedByteLengths.includes(v.byteLength); + } + return false; + } + + const stringSchema = (optional = false) => + z.string() + .refine((s) => + optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), { + message: msgs.required, + }) + .refine((s) => + s === "" || tryParse(s) !== null, { message: msgs.format }) + .refine((s) => + s === "" || isValidString(s), { + message: msgs.length, + params: { bits: bitsLabel }, + }); + + const bytesSchema = (optional = false): ZodType => + z.instanceof(Uint8Array) + .refine( + (arr) => + optional || arr.byteLength !== 0 || allowedByteLengths.includes(0), + { message: msgs.required }, + ) + .refine( + (arr) => optional || allowedByteLengths.includes(arr.byteLength), + { message: msgs.length, params: { bits: bitsLabel } }, + ); + + return { + allowedByteLengths, + msgs, + tryParseStringKey: tryParse, + isValidKey, + stringSchema, + bytesSchema, + }; +} diff --git a/src/validation/rasterSource.ts b/src/validation/rasterSource.ts deleted file mode 100644 index 965206d4..00000000 --- a/src/validation/rasterSource.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsArray, IsBoolean, IsNumber, IsString, IsUrl } from "class-validator"; - -import type { RasterSource } from "@core/stores/appStore.ts"; - -export class MapValidation { - @IsArray() - rasterSources: MapValidation_RasterSources[]; -} - -export class MapValidation_RasterSources implements RasterSource { - @IsBoolean() - enabled: boolean; - - @IsString() - title: string; - - @IsUrl() - tiles: string; - - @IsNumber() - tileSize: number; -} diff --git a/src/validation/validate.ts b/src/validation/validate.ts index 7130add5..f147e0c6 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -1,7 +1,7 @@ -import { ZodError, ZodSchema } from "zod"; +import { ZodError, ZodType } from "zod/v4"; export function validateSchema( - schema: ZodSchema, + schema: ZodType, data: unknown, ): { success: true; data: T } | { success: false; errors: ZodError["issues"] } { const result = schema.safeParse(data);