Zod config validation (#635)
* Zod WIP * Zod form validation * DynamicForm testing * Fix linting * Delete rasterSource.ts --------- Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
co-authored by
philon-
parent
df036d3904
commit
1cbd98ec53
+1
-2
@@ -59,7 +59,6 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.4",
|
"@radix-ui/react-tooltip": "^1.2.4",
|
||||||
"@turf/turf": "^7.2.0",
|
"@turf/turf": "^7.2.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"class-validator": "^0.14.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -82,7 +81,7 @@
|
|||||||
"rfc4648": "^1.5.4",
|
"rfc4648": "^1.5.4",
|
||||||
"vite-plugin-i18n-ally": "^6.0.1",
|
"vite-plugin-i18n-ally": "^6.0.1",
|
||||||
"vite-plugin-node-polyfills": "^0.23.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
"zod": "^3.24.3",
|
"zod": "^3.25.0",
|
||||||
"zustand": "5.0.4"
|
"zustand": "5.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx";
|
||||||
|
import { DynamicForm } from "./DynamicForm.tsx";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const addErrorMock = vi.fn();
|
||||||
|
const removeErrorMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@core/stores/appStore.ts", () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
addError: addErrorMock,
|
||||||
|
removeError: removeErrorMock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("DynamicForm", () => {
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(3, { message: "Too short" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fieldGroups = [
|
||||||
|
{
|
||||||
|
label: "Test Group",
|
||||||
|
description: "Testing validation",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
id: "name",
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
description: "Enter your name",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("shows validation error when input is too short", async () => {
|
||||||
|
render(
|
||||||
|
<DynamicForm<z.infer<typeof schema>>
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
validationSchema={schema}
|
||||||
|
defaultValues={{ name: "" }}
|
||||||
|
fieldGroups={fieldGroups}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: "ab" } });
|
||||||
|
|
||||||
|
const error = await screen.findByText(
|
||||||
|
"formValidation.tooSmall.string",
|
||||||
|
);
|
||||||
|
expect(error).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears validation error when input becomes valid", async () => {
|
||||||
|
render(
|
||||||
|
<DynamicForm<z.infer<typeof schema>>
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
validationSchema={schema}
|
||||||
|
defaultValues={{ name: "" }}
|
||||||
|
fieldGroups={fieldGroups}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: "ab" } });
|
||||||
|
expect(
|
||||||
|
await screen.findByText("formValidation.tooSmall.string"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: "abcd" } });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.queryByText("formValidation.tooSmall.string"),
|
||||||
|
).toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSubmit when form is valid onChange", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(
|
||||||
|
<DynamicForm<z.infer<typeof schema>>
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={schema}
|
||||||
|
defaultValues={{ name: "" }}
|
||||||
|
fieldGroups={fieldGroups}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: "ab" } });
|
||||||
|
expect(
|
||||||
|
await screen.findByText("formValidation.tooSmall.string"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: "abcd" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(
|
||||||
|
{ name: "abcd" },
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(
|
||||||
|
<DynamicForm<z.infer<typeof schema>>
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitType="onSubmit"
|
||||||
|
hasSubmitButton
|
||||||
|
validationSchema={schema}
|
||||||
|
defaultValues={{ name: "" }}
|
||||||
|
fieldGroups={fieldGroups}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole("button", { name: /submit/i });
|
||||||
|
expect(btn).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.input(screen.getByLabelText("Name"), { target: { value: "ab" } });
|
||||||
|
await screen.findByText("formValidation.tooSmall.string");
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.input(screen.getByLabelText("Name"), {
|
||||||
|
target: { value: "abcd" },
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText("formValidation.tooSmall.string")).toBeNull()
|
||||||
|
);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders defaultValues correctly", () => {
|
||||||
|
render(
|
||||||
|
<DynamicForm<{ name: string }>
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
// no validationSchema
|
||||||
|
defaultValues={{ name: "Alice" }}
|
||||||
|
fieldGroups={[
|
||||||
|
{
|
||||||
|
label: "Group",
|
||||||
|
description: "",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "name",
|
||||||
|
label: "Name",
|
||||||
|
description: "",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Name") as HTMLInputElement;
|
||||||
|
expect(input.value).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles disabled state based on disabledBy rules", async () => {
|
||||||
|
const schema = z.object({
|
||||||
|
enable: z.boolean(),
|
||||||
|
follow: z.string(),
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<DynamicForm<z.infer<typeof schema>>
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
validationSchema={schema}
|
||||||
|
defaultValues={{ enable: false, follow: "" }}
|
||||||
|
fieldGroups={[
|
||||||
|
{
|
||||||
|
label: "Group",
|
||||||
|
description: "",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "toggle",
|
||||||
|
name: "enable",
|
||||||
|
label: "enable",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "follow",
|
||||||
|
label: "follow",
|
||||||
|
description: "",
|
||||||
|
disabledBy: [{ fieldName: "enable" }],
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const enable = screen.getByRole("switch", {
|
||||||
|
name: "enable",
|
||||||
|
}) as HTMLInputElement;
|
||||||
|
|
||||||
|
const follow = screen.getByLabelText("follow") as HTMLInputElement;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(enable.getAttribute("aria-checked")).toBe("false");
|
||||||
|
expect(follow).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(enable);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(enable.getAttribute("aria-checked")).toBe("true");
|
||||||
|
expect(follow).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always calls onSubmit onChange when no validationSchema is provided", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
render(
|
||||||
|
<DynamicForm<{ foo: string }>
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
// no validationSchema
|
||||||
|
defaultValues={{ foo: "" }}
|
||||||
|
fieldGroups={[
|
||||||
|
{
|
||||||
|
label: "G",
|
||||||
|
description: "",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "foo",
|
||||||
|
label: "Foo",
|
||||||
|
description: "",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Foo") as HTMLInputElement;
|
||||||
|
fireEvent.input(input, { target: { value: "bar" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs errors to appStore when formId is set", async () => {
|
||||||
|
const { addError, removeError } = useAppStore();
|
||||||
|
const schema = z.object({ foo: z.string().min(2) });
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
label: "G",
|
||||||
|
description: "",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "foo",
|
||||||
|
label: "Foo",
|
||||||
|
description: "",
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DynamicForm<z.infer<typeof schema>>
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
formId="myForm"
|
||||||
|
validationSchema={schema}
|
||||||
|
defaultValues={{ foo: "" }}
|
||||||
|
fieldGroups={groups}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Foo") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: "a" } });
|
||||||
|
await screen.findByText(/tooSmall/i);
|
||||||
|
|
||||||
|
expect(addError).toHaveBeenCalledWith("foo", "");
|
||||||
|
expect(addError).toHaveBeenCalledWith("myForm", "");
|
||||||
|
|
||||||
|
fireEvent.input(input, { target: { value: "abc" } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(removeError).toHaveBeenCalledWith("foo");
|
||||||
|
expect(removeError).toHaveBeenCalledWith("myForm");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,19 @@ import {
|
|||||||
type Control,
|
type Control,
|
||||||
type DefaultValues,
|
type DefaultValues,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
get,
|
||||||
type Path,
|
type Path,
|
||||||
type SubmitHandler,
|
type SubmitHandler,
|
||||||
useForm,
|
useForm,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
import { Heading } from "@components/UI/Typography/Heading.tsx";
|
||||||
|
import { ZodType } from "zod/v4";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||||
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
|
import { dotPaths } from "@core/utils/dotPath.ts";
|
||||||
|
|
||||||
interface DisabledBy<T> {
|
interface DisabledBy<T> {
|
||||||
fieldName: Path<T>;
|
fieldName: Path<T>;
|
||||||
@@ -51,6 +59,8 @@ export interface DynamicFormProps<T extends FieldValues> {
|
|||||||
validationText?: string;
|
validationText?: string;
|
||||||
fields: FieldProps<T>[];
|
fields: FieldProps<T>[];
|
||||||
}[];
|
}[];
|
||||||
|
validationSchema?: ZodType<T>;
|
||||||
|
formId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DynamicForm<T extends FieldValues>({
|
export function DynamicForm<T extends FieldValues>({
|
||||||
@@ -59,11 +69,45 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
hasSubmitButton,
|
hasSubmitButton,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
fieldGroups,
|
fieldGroups,
|
||||||
|
validationSchema,
|
||||||
|
formId,
|
||||||
}: DynamicFormProps<T>) {
|
}: DynamicFormProps<T>) {
|
||||||
const { handleSubmit, control, getValues } = useForm<T>({
|
const { t } = useTranslation();
|
||||||
mode: submitType,
|
const {
|
||||||
|
addError,
|
||||||
|
removeError,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
const methods = useForm<
|
||||||
|
T
|
||||||
|
>({
|
||||||
|
mode: "onChange",
|
||||||
defaultValues: defaultValues,
|
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 = (
|
const isDisabled = (
|
||||||
disabledBy?: DisabledBy<T>[],
|
disabledBy?: DisabledBy<T>[],
|
||||||
@@ -86,46 +130,66 @@ export function DynamicForm<T extends FieldValues>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<FormProvider {...methods}>
|
||||||
className="space-y-8"
|
<form
|
||||||
{...(submitType === "onSubmit" ? { onSubmit: handleSubmit(onSubmit) } : {
|
className="space-y-8"
|
||||||
onChange: handleSubmit(onSubmit),
|
{...(submitType === "onSubmit"
|
||||||
})}
|
? { onSubmit: handleSubmit(onSubmit) }
|
||||||
>
|
: { onChange: handleSubmit(onSubmit) })}
|
||||||
{fieldGroups.map((fieldGroup) => (
|
>
|
||||||
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
|
{fieldGroups.map((fieldGroup) => (
|
||||||
<div>
|
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
|
||||||
<Heading as="h4" className="font-medium">
|
<div>
|
||||||
{fieldGroup.label}
|
<Heading as="h4" className="font-medium">
|
||||||
</Heading>
|
{fieldGroup.label}
|
||||||
<Subtle>{fieldGroup.description}</Subtle>
|
</Heading>
|
||||||
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
|
<Subtle>{fieldGroup.description}</Subtle>
|
||||||
</div>
|
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
|
||||||
|
</div>
|
||||||
|
|
||||||
{fieldGroup.fields.map((field) => {
|
{fieldGroup.fields.map((field) => {
|
||||||
return (
|
const error = get(formState.errors, field.name as string);
|
||||||
<FieldWrapper
|
return (
|
||||||
key={field.label}
|
<FieldWrapper
|
||||||
label={field.label}
|
key={field.label}
|
||||||
fieldName={field.name}
|
label={field.label}
|
||||||
description={field.description}
|
fieldName={field.name}
|
||||||
valid={field.validationText === undefined ||
|
description={field.description}
|
||||||
field.validationText === ""}
|
valid={validationSchema // keep backwards compat with not updated cfg pages
|
||||||
validationText={field.validationText}
|
? !error
|
||||||
>
|
: field.validationText === undefined ||
|
||||||
<DynamicFormField
|
field.validationText === ""}
|
||||||
field={field}
|
validationText={validationSchema
|
||||||
control={control}
|
? (error
|
||||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
? String(
|
||||||
/>
|
t([`formValidation.${error.type}`, error.message], {
|
||||||
</FieldWrapper>
|
returnObjects: false,
|
||||||
);
|
...error.params,
|
||||||
})}
|
}),
|
||||||
</div>
|
)
|
||||||
))}
|
: "")
|
||||||
{hasSubmitButton && (
|
: field.validationText}
|
||||||
<Button type="submit" variant="outline">Submit</Button>
|
>
|
||||||
)}
|
<DynamicFormField
|
||||||
</form>
|
field={field}
|
||||||
|
control={control}
|
||||||
|
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||||
|
/>
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{hasSubmitButton && (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!formState.isValid}
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import type {
|
|||||||
} from "@components/Form/DynamicForm.tsx";
|
} from "@components/Form/DynamicForm.tsx";
|
||||||
import type { ButtonVariant } from "../UI/Button.tsx";
|
import type { ButtonVariant } from "../UI/Button.tsx";
|
||||||
import { Generator } from "@components/UI/Generator.tsx";
|
import { Generator } from "@components/UI/Generator.tsx";
|
||||||
import type { ChangeEventHandler } from "react";
|
import { Controller, type FieldValues, useFormContext } from "react-hook-form";
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
|
||||||
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
import { usePasswordVisibilityToggle } from "@core/hooks/usePasswordVisibilityToggle.ts";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "passwordGenerator";
|
type: "passwordGenerator";
|
||||||
@@ -14,8 +14,8 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
bits?: { text: string; value: string; key: string }[];
|
bits?: { text: string; value: string; key: string }[];
|
||||||
devicePSKBitCount: number;
|
devicePSKBitCount: number;
|
||||||
inputChange: ChangeEventHandler<HTMLInputElement> | undefined;
|
inputChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||||
selectChange: (event: string) => void;
|
selectChange?: (event: string) => void;
|
||||||
actionButtons: {
|
actionButtons: {
|
||||||
text: string;
|
text: string;
|
||||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
@@ -32,19 +32,27 @@ export function PasswordGenerator<T extends FieldValues>({
|
|||||||
disabled,
|
disabled,
|
||||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||||
const { isVisible } = usePasswordVisibilityToggle();
|
const { isVisible } = usePasswordVisibilityToggle();
|
||||||
|
const { trigger } = useFormContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trigger(field.name);
|
||||||
|
}, [field.devicePSKBitCount, field.name, trigger]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
name={field.name}
|
name={field.name}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, ...rest } }) => (
|
render={({ field: { value, onChange, ...rest } }) => (
|
||||||
<Generator
|
<Generator
|
||||||
type={field.hide && !isVisible ? "password" : "text"}
|
type={field.hide && !isVisible ? "password" : "text"}
|
||||||
id={field.id}
|
id={field.id}
|
||||||
devicePSKBitCount={field.devicePSKBitCount}
|
devicePSKBitCount={field.devicePSKBitCount}
|
||||||
bits={field.bits}
|
bits={field.bits}
|
||||||
inputChange={field.inputChange}
|
inputChange={(e) => {
|
||||||
selectChange={field.selectChange}
|
if (field.inputChange) field.inputChange(e);
|
||||||
|
onChange(e);
|
||||||
|
}}
|
||||||
|
selectChange={field.selectChange ?? (() => {})}
|
||||||
value={value}
|
value={value}
|
||||||
variant={field.validationText ? "invalid" : "default"}
|
variant={field.validationText ? "invalid" : "default"}
|
||||||
actionButtons={field.actionButtons}
|
actionButtons={field.actionButtons}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ZodType } from "zod/v4";
|
||||||
|
import type {
|
||||||
|
FieldError,
|
||||||
|
FieldValues,
|
||||||
|
Resolver,
|
||||||
|
ResolverOptions,
|
||||||
|
ResolverResult,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
export function createZodResolver<T extends FieldValues>(
|
||||||
|
schema: ZodType<T, unknown>,
|
||||||
|
): Resolver<T, unknown> {
|
||||||
|
return (
|
||||||
|
values: T,
|
||||||
|
_context: unknown,
|
||||||
|
_options?: ResolverOptions<T>,
|
||||||
|
): ResolverResult<T> => {
|
||||||
|
const result = schema.safeParse(values);
|
||||||
|
if (result.success) {
|
||||||
|
return {
|
||||||
|
values: result.data,
|
||||||
|
errors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: Record<
|
||||||
|
string,
|
||||||
|
FieldError & { params?: Record<string, unknown> }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const { path, code, message, ...params } = issue;
|
||||||
|
const key = path.join(".");
|
||||||
|
|
||||||
|
const suffix = "format" in params
|
||||||
|
? params.format
|
||||||
|
: "origin" in params
|
||||||
|
? params.origin
|
||||||
|
: "expected" in params
|
||||||
|
? params.expected
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const newCode = code.replace(
|
||||||
|
/_([a-z])/g,
|
||||||
|
(_, char) => char.toUpperCase(),
|
||||||
|
) + (suffix ? `.${suffix}` : "");
|
||||||
|
|
||||||
|
const fieldError: FieldError & { params?: Record<string, unknown> } = {
|
||||||
|
type: newCode,
|
||||||
|
message: message,
|
||||||
|
...(Object.keys(params).length ? { params } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!errors[key]) {
|
||||||
|
errors[key] = fieldError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values: {} as T,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useToast } from "@core/hooks/useToast.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
@@ -6,9 +6,10 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
|||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
import { fromByteArray, toByteArray } from "base64-js";
|
||||||
import cryptoRandomString from "crypto-random-string";
|
import cryptoRandomString from "crypto-random-string";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
export interface SettingsPanelProps {
|
||||||
channel: Protobuf.Channel.Channel;
|
channel: Protobuf.Channel.Channel;
|
||||||
@@ -19,16 +20,24 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [pass, setPass] = useState<string>(
|
|
||||||
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
|
||||||
);
|
|
||||||
const [bitCount, setBits] = useState<number>(
|
|
||||||
channel?.settings?.psk.length ?? 16,
|
|
||||||
);
|
|
||||||
const [validationText, setValidationText] = useState<string>();
|
|
||||||
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
|
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
const [pass, setPass] = useState<string>(
|
||||||
|
fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)),
|
||||||
|
);
|
||||||
|
const [byteCount, setBytes] = useState<number>(
|
||||||
|
channel?.settings?.psk.length ?? 16,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChannelValidationSchema = useMemo(
|
||||||
|
() => {
|
||||||
|
return makeChannelSchema(byteCount);
|
||||||
|
},
|
||||||
|
[byteCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
type ChannelValidation = zodInfer<typeof ChannelValidationSchema>;
|
||||||
|
|
||||||
const onSubmit = (data: ChannelValidation) => {
|
const onSubmit = (data: ChannelValidation) => {
|
||||||
const channel = create(Protobuf.Channel.ChannelSchema, {
|
const channel = create(Protobuf.Channel.ChannelSchema, {
|
||||||
@@ -43,8 +52,12 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
connection?.setChannel(channel).then(() => {
|
connection?.setChannel(channel).then(() => {
|
||||||
|
console.debug(t("toast.savedChannel.title", {
|
||||||
|
ns: "ui",
|
||||||
|
channelName: channel.settings?.name,
|
||||||
|
}));
|
||||||
toast({
|
toast({
|
||||||
title: t("toast.savedChannel", {
|
title: t("toast.savedChannel.title", {
|
||||||
ns: "ui",
|
ns: "ui",
|
||||||
channelName: channel.settings?.name,
|
channelName: channel.settings?.name,
|
||||||
}),
|
}),
|
||||||
@@ -54,15 +67,14 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const preSharedKeyRegenerate = () => {
|
const preSharedKeyRegenerate = () => {
|
||||||
setPass(
|
const newPsk = btoa(
|
||||||
btoa(
|
cryptoRandomString({
|
||||||
cryptoRandomString({
|
length: byteCount ?? 0,
|
||||||
length: bitCount ?? 0,
|
type: "alphanumeric",
|
||||||
type: "alphanumeric",
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setValidationText(undefined);
|
setPass(newPsk);
|
||||||
|
|
||||||
setPreSharedDialogOpen(false);
|
setPreSharedDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,26 +82,13 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
setPreSharedDialogOpen(true);
|
setPreSharedDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validatePass = (input: string, count: number) => {
|
|
||||||
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
|
|
||||||
setValidationText(
|
|
||||||
t("validation.pskInvalid", { bits: count * 8 }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setValidationText(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const inputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const psk = e.currentTarget?.value;
|
setPass(e.currentTarget?.value);
|
||||||
setPass(psk);
|
|
||||||
validatePass(psk, bitCount);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectChangeEvent = (e: string) => {
|
const selectChangeEvent = (e: string) => {
|
||||||
const count = Number.parseInt(e);
|
const count = Number.parseInt(e);
|
||||||
setBits(count);
|
setBytes(count);
|
||||||
validatePass(pass, count);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +96,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
<DynamicForm<ChannelValidation>
|
<DynamicForm<ChannelValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
submitType="onSubmit"
|
submitType="onSubmit"
|
||||||
|
validationSchema={ChannelValidationSchema}
|
||||||
hasSubmitButton
|
hasSubmitButton
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
...channel,
|
...channel,
|
||||||
@@ -141,8 +141,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
id: "channel-psk",
|
id: "channel-psk",
|
||||||
label: t("psk.label"),
|
label: t("psk.label"),
|
||||||
description: t("psk.description"),
|
description: t("psk.description"),
|
||||||
validationText: validationText,
|
devicePSKBitCount: byteCount ?? 0,
|
||||||
devicePSKBitCount: bitCount ?? 0,
|
|
||||||
inputChange: inputChangeEvent,
|
inputChange: inputChangeEvent,
|
||||||
selectChange: selectChangeEvent,
|
selectChange: selectChangeEvent,
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
|
|||||||
@@ -1,63 +1,18 @@
|
|||||||
import { useAppStore } from "../../../core/stores/appStore.ts";
|
import {
|
||||||
import type { BluetoothValidation } from "@app/validation/config/bluetooth.ts";
|
type BluetoothValidation,
|
||||||
|
BluetoothValidationSchema,
|
||||||
|
} from "@app/validation/config/bluetooth.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const Bluetooth = () => {
|
export const Bluetooth = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
const {
|
|
||||||
hasErrors,
|
|
||||||
getErrorMessage,
|
|
||||||
hasFieldError,
|
|
||||||
addError,
|
|
||||||
removeError,
|
|
||||||
clearErrors,
|
|
||||||
} = useAppStore();
|
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const [bluetoothPin, setBluetoothPin] = useState(
|
|
||||||
config?.bluetooth?.fixedPin.toString() ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const validateBluetoothPin = (pin: string) => {
|
|
||||||
// if empty show error they need a pin set
|
|
||||||
if (pin === "") {
|
|
||||||
return addError("fixedPin", t("bluetooth.validation.pinRequired"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// clear any existing errors
|
|
||||||
clearErrors();
|
|
||||||
|
|
||||||
// if it starts with 0 show error
|
|
||||||
if (pin[0] === "0") {
|
|
||||||
return addError(
|
|
||||||
"fixedPin",
|
|
||||||
t("bluetooth.validation.pinCannotStartWithZero"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// if it's not 6 digits show error
|
|
||||||
if (pin.length < 6) {
|
|
||||||
return addError("fixedPin", t("bluetooth.validation.pinMustBeSixDigits"));
|
|
||||||
}
|
|
||||||
|
|
||||||
removeError("fixedPin");
|
|
||||||
};
|
|
||||||
|
|
||||||
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6);
|
|
||||||
setBluetoothPin(numericValue);
|
|
||||||
validateBluetoothPin(numericValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (data: BluetoothValidation) => {
|
const onSubmit = (data: BluetoothValidation) => {
|
||||||
if (hasErrors()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -71,6 +26,8 @@ export const Bluetooth = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<BluetoothValidation>
|
<DynamicForm<BluetoothValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={BluetoothValidationSchema}
|
||||||
|
formId="Config_BluetoothConfig"
|
||||||
defaultValues={config.bluetooth}
|
defaultValues={config.bluetooth}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
@@ -89,12 +46,6 @@ export const Bluetooth = () => {
|
|||||||
name: "mode",
|
name: "mode",
|
||||||
label: t("bluetooth.pairingMode.label"),
|
label: t("bluetooth.pairingMode.label"),
|
||||||
description: t("bluetooth.pairingMode.description"),
|
description: t("bluetooth.pairingMode.description"),
|
||||||
selectChange: (e) => {
|
|
||||||
if (e !== "1") {
|
|
||||||
setBluetoothPin("");
|
|
||||||
removeError("fixedPin");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
@@ -110,24 +61,6 @@ export const Bluetooth = () => {
|
|||||||
name: "fixedPin",
|
name: "fixedPin",
|
||||||
label: t("bluetooth.pin.label"),
|
label: t("bluetooth.pin.label"),
|
||||||
description: t("bluetooth.pin.description"),
|
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -24,6 +27,8 @@ export const Device = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<DeviceValidation>
|
<DynamicForm<DeviceValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={DeviceValidationSchema}
|
||||||
|
formId="Config_DeviceConfig"
|
||||||
defaultValues={config.device}
|
defaultValues={config.device}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const Display = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<DisplayValidation>
|
<DynamicForm<DisplayValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={DisplayValidationSchema}
|
||||||
|
formId="Config_DisplayConfig"
|
||||||
defaultValues={config.display}
|
defaultValues={config.display}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
@@ -91,7 +96,7 @@ export const Display = () => {
|
|||||||
label: t("display.oledType.label"),
|
label: t("display.oledType.label"),
|
||||||
description: t("display.oledType.description"),
|
description: t("display.oledType.description"),
|
||||||
properties: {
|
properties: {
|
||||||
enumValue: Protobuf.Config.Config_Displayjonfig_OledType,
|
enumValue: Protobuf.Config.Config_DisplayConfig_OledType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const LoRa = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<LoRaValidation>
|
<DynamicForm<LoRaValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={LoRaValidationSchema}
|
||||||
|
formId="Config_LoRaConfig"
|
||||||
defaultValues={config.lora}
|
defaultValues={config.lora}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,20 +10,12 @@ import {
|
|||||||
convertIpAddressToInt,
|
convertIpAddressToInt,
|
||||||
} from "@core/utils/ip.ts";
|
} from "@core/utils/ip.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { validateSchema } from "@app/validation/validate.ts";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export const Network = () => {
|
export const Network = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const onSubmit = (data: NetworkValidation) => {
|
const onSubmit = (data: NetworkValidation) => {
|
||||||
const result = validateSchema(NetworkValidationSchema, data);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
console.error("Validation errors:", result.errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -48,6 +40,8 @@ export const Network = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<NetworkValidation>
|
<DynamicForm<NetworkValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={NetworkValidationSchema}
|
||||||
|
formId="Config_NetworkConfig"
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
...config.network,
|
...config.network,
|
||||||
ipv4Config: {
|
ipv4Config: {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import {
|
|||||||
type FlagName,
|
type FlagName,
|
||||||
usePositionFlags,
|
usePositionFlags,
|
||||||
} from "@core/hooks/usePositionFlags.ts";
|
} 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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -41,6 +44,8 @@ export const Position = () => {
|
|||||||
data.positionFlags = flagsValue;
|
data.positionFlags = flagsValue;
|
||||||
return onSubmit(data);
|
return onSubmit(data);
|
||||||
}}
|
}}
|
||||||
|
validationSchema={PositionValidationSchema}
|
||||||
|
formId="Config_PositionConfig"
|
||||||
defaultValues={config.position}
|
defaultValues={config.position}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const Power = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<PowerValidation>
|
<DynamicForm<PowerValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={PowerValidationSchema}
|
||||||
|
formId="Config_PowerConfig"
|
||||||
defaultValues={config.power}
|
defaultValues={config.power}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,116 +2,46 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"
|
|||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
import { getX25519PrivateKey, getX25519PublicKey } from "@core/utils/x25519.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 { create } from "@bufbuild/protobuf";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { fromByteArray, toByteArray } from "base64-js";
|
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";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type KeyState = {
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
privateKeyDialogOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const Security = () => {
|
export const Security = () => {
|
||||||
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
||||||
const {
|
const { removeError } = useAppStore();
|
||||||
hasErrors,
|
|
||||||
getErrorMessage,
|
|
||||||
hasFieldError,
|
|
||||||
addError,
|
|
||||||
removeError,
|
|
||||||
clearErrors,
|
|
||||||
} = useAppStore();
|
|
||||||
const { t } = useTranslation("deviceConfig");
|
const { t } = useTranslation("deviceConfig");
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(securityReducer, {
|
const [keyState, setKeyState] = useState<KeyState>(() => ({
|
||||||
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
publicKey: fromByteArray(config?.security?.publicKey ?? new Uint8Array(0)),
|
||||||
privateKeyVisible: false,
|
privateKey: fromByteArray(
|
||||||
adminKeyVisible: [false, false, false],
|
config?.security?.privateKey ?? new Uint8Array(0),
|
||||||
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)),
|
|
||||||
],
|
|
||||||
privateKeyDialogOpen: false,
|
privateKeyDialogOpen: false,
|
||||||
isManaged: config.security?.isManaged ?? false,
|
}));
|
||||||
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
|
||||||
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
|
||||||
serialEnabled: config.security?.serialEnabled ?? false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const validateKey = (
|
const onSubmit = (data: RawSecurity) => {
|
||||||
input: string,
|
const payload: ParsedSecurity = {
|
||||||
count: number,
|
...data,
|
||||||
fieldName: "privateKey" | "adminKey",
|
privateKey: toByteArray(keyState.privateKey),
|
||||||
fieldIndex?: number,
|
publicKey: toByteArray(keyState.publicKey),
|
||||||
) => {
|
|
||||||
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),
|
|
||||||
adminKey: [
|
adminKey: [
|
||||||
overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]),
|
toByteArray(data.adminKey.at(0) ?? ""),
|
||||||
overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]),
|
toByteArray(data.adminKey.at(1) ?? ""),
|
||||||
overrides?.adminKey?.[0] ?? toByteArray(state.adminKey[0]),
|
toByteArray(data.adminKey.at(2) ?? ""),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,137 +49,79 @@ export const Security = () => {
|
|||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
case: "security",
|
case: "security",
|
||||||
value: { ...base, ...overrides },
|
value: payload,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const pkiRegenerate = () => {
|
const pkiRegenerate = () => {
|
||||||
clearErrors();
|
|
||||||
const privateKey = getX25519PrivateKey();
|
const privateKey = getX25519PrivateKey();
|
||||||
const publicKey = getX25519PublicKey(privateKey);
|
|
||||||
|
|
||||||
dispatch({
|
updatePublicKey(fromByteArray(privateKey));
|
||||||
type: "REGENERATE_PRIV_PUB_KEY",
|
|
||||||
payload: {
|
|
||||||
privateKey: fromByteArray(privateKey),
|
|
||||||
publicKey: fromByteArray(publicKey),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
validateKey(
|
setKeyState((prev) => ({
|
||||||
fromByteArray(privateKey),
|
...prev,
|
||||||
state.privateKeyBitCount,
|
privateKey: fromByteArray(privateKey),
|
||||||
"privateKey",
|
privateKeyDialogOpen: false,
|
||||||
);
|
}));
|
||||||
|
|
||||||
if (!hasErrors()) {
|
removeError("privateKey");
|
||||||
setSecurityPayload({
|
};
|
||||||
|
|
||||||
|
const updatePublicKey = (privateKey: string) => {
|
||||||
|
try {
|
||||||
|
const publicKey = fromByteArray(
|
||||||
|
getX25519PublicKey(toByteArray(privateKey)),
|
||||||
|
);
|
||||||
|
setKeyState((prev) => ({
|
||||||
|
...prev,
|
||||||
privateKey: privateKey,
|
privateKey: privateKey,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
removeError("publicKey");
|
||||||
|
} catch (_e) {
|
||||||
|
setKeyState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
privateKey: privateKey,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const privateKeyInputChangeEvent = (
|
const bits = [
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
{
|
||||||
) => {
|
text: t("security.256bit"),
|
||||||
const privateKeyB64String = e.target.value;
|
value: "32",
|
||||||
dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String });
|
key: "bit256",
|
||||||
validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey");
|
},
|
||||||
|
];
|
||||||
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
|
|
||||||
dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) });
|
|
||||||
|
|
||||||
if (!hasErrors()) {
|
|
||||||
setSecurityPayload({
|
|
||||||
privateKey: toByteArray(privateKeyB64String),
|
|
||||||
publicKey: publicKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const adminKeyInputChangeEvent = (
|
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
fieldIndex?: number,
|
|
||||||
) => {
|
|
||||||
if (fieldIndex === undefined) return;
|
|
||||||
const psk = e.target.value;
|
|
||||||
|
|
||||||
const payload = [
|
|
||||||
fieldIndex === 0 ? psk : state.adminKey[0],
|
|
||||||
fieldIndex === 1 ? psk : state.adminKey[1],
|
|
||||||
fieldIndex === 2 ? psk : state.adminKey[2],
|
|
||||||
] satisfies [string, string, string];
|
|
||||||
|
|
||||||
dispatch({ type: "SET_ADMIN_KEY", payload: payload });
|
|
||||||
validateKey(psk, state.privateKeyBitCount, "adminKey", fieldIndex);
|
|
||||||
|
|
||||||
if (!hasErrors()) {
|
|
||||||
setSecurityPayload({
|
|
||||||
adminKey: payload.map(toByteArray) as [
|
|
||||||
Uint8Array,
|
|
||||||
Uint8Array,
|
|
||||||
Uint8Array,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DynamicForm<SecurityValidation>
|
<DynamicForm<RawSecurity>
|
||||||
onSubmit={() => {}}
|
onSubmit={onSubmit}
|
||||||
submitType="onSubmit"
|
validationSchema={RawSecuritySchema}
|
||||||
|
formId="Config_SecurityConfig"
|
||||||
defaultValues={{
|
defaultValues={{
|
||||||
...config.security,
|
...config.security,
|
||||||
...{
|
...{
|
||||||
adminKey: state.adminKey,
|
privateKey: fromByteArray(
|
||||||
privateKey: state.privateKey,
|
config?.security?.privateKey ?? new Uint8Array(0),
|
||||||
publicKey: state.publicKey,
|
),
|
||||||
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
publicKey: fromByteArray(
|
||||||
isManaged: config.security?.isManaged ?? false,
|
config?.security?.publicKey ?? new Uint8Array(0),
|
||||||
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
),
|
||||||
serialEnabled: config.security?.serialEnabled ?? false,
|
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={[
|
fieldGroups={[
|
||||||
@@ -263,28 +135,20 @@ export const Security = () => {
|
|||||||
name: "privateKey",
|
name: "privateKey",
|
||||||
label: t("security.privateKey.label"),
|
label: t("security.privateKey.label"),
|
||||||
description: t("security.privateKey.description"),
|
description: t("security.privateKey.description"),
|
||||||
bits: [
|
bits,
|
||||||
{
|
devicePSKBitCount: 32,
|
||||||
text: t("security.256bit"),
|
hide: true,
|
||||||
value: "32",
|
inputChange: (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
key: "bit256",
|
updatePublicKey(e.target.value);
|
||||||
},
|
},
|
||||||
],
|
|
||||||
validationText: hasFieldError("privateKey")
|
|
||||||
? getErrorMessage("privateKey")
|
|
||||||
: "",
|
|
||||||
devicePSKBitCount: state.privateKeyBitCount,
|
|
||||||
inputChange: privateKeyInputChangeEvent,
|
|
||||||
selectChange: () => {},
|
|
||||||
hide: !state.privateKeyVisible,
|
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
{
|
{
|
||||||
text: t("button.generate"),
|
text: t("button.generate"),
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
dispatch({
|
setKeyState((prev) => ({
|
||||||
type: "SHOW_PRIVATE_KEY_DIALOG",
|
...prev,
|
||||||
payload: true,
|
privateKeyDialogOpen: true,
|
||||||
}),
|
})),
|
||||||
variant: "success",
|
variant: "success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -294,9 +158,10 @@ export const Security = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.privateKey,
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
showPasswordToggle: true,
|
showPasswordToggle: true,
|
||||||
|
|
||||||
|
value: keyState.privateKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -306,8 +171,8 @@ export const Security = () => {
|
|||||||
disabled: true,
|
disabled: true,
|
||||||
description: t("security.publicKey.description"),
|
description: t("security.publicKey.description"),
|
||||||
properties: {
|
properties: {
|
||||||
value: state.publicKey,
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
|
value: keyState.publicKey,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -322,26 +187,14 @@ export const Security = () => {
|
|||||||
id: "adminKey0Input",
|
id: "adminKey0Input",
|
||||||
label: t("security.primaryAdminKey.label"),
|
label: t("security.primaryAdminKey.label"),
|
||||||
description: t("security.primaryAdminKey.description"),
|
description: t("security.primaryAdminKey.description"),
|
||||||
validationText: hasFieldError("adminKey0")
|
bits,
|
||||||
? getErrorMessage("adminKey0")
|
devicePSKBitCount: 32,
|
||||||
: "",
|
hide: true,
|
||||||
inputChange: (e) => adminKeyInputChangeEvent(e, 0),
|
|
||||||
selectChange: () => {},
|
|
||||||
bits: [
|
|
||||||
{
|
|
||||||
text: t("security.256bit"),
|
|
||||||
value: "32",
|
|
||||||
key: "bit256",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
devicePSKBitCount: state.privateKeyBitCount,
|
|
||||||
hide: !state.adminKeyVisible[0],
|
|
||||||
actionButtons: [],
|
actionButtons: [],
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{ fieldName: "adminChannelEnabled", invert: true },
|
{ fieldName: "adminChannelEnabled", invert: true },
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.adminKey[0],
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
showPasswordToggle: true,
|
showPasswordToggle: true,
|
||||||
},
|
},
|
||||||
@@ -352,26 +205,14 @@ export const Security = () => {
|
|||||||
id: "adminKey1Input",
|
id: "adminKey1Input",
|
||||||
label: t("security.secondaryAdminKey.label"),
|
label: t("security.secondaryAdminKey.label"),
|
||||||
description: t("security.secondaryAdminKey.description"),
|
description: t("security.secondaryAdminKey.description"),
|
||||||
validationText: hasFieldError("adminKey1")
|
bits,
|
||||||
? getErrorMessage("adminKey1")
|
devicePSKBitCount: 32,
|
||||||
: "",
|
hide: true,
|
||||||
inputChange: (e) => adminKeyInputChangeEvent(e, 1),
|
|
||||||
selectChange: () => {},
|
|
||||||
bits: [
|
|
||||||
{
|
|
||||||
text: t("security.256bit"),
|
|
||||||
value: "32",
|
|
||||||
key: "bit256",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
devicePSKBitCount: state.privateKeyBitCount,
|
|
||||||
hide: !state.adminKeyVisible[1],
|
|
||||||
actionButtons: [],
|
actionButtons: [],
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{ fieldName: "adminChannelEnabled", invert: true },
|
{ fieldName: "adminChannelEnabled", invert: true },
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.adminKey[1],
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
showPasswordToggle: true,
|
showPasswordToggle: true,
|
||||||
},
|
},
|
||||||
@@ -382,26 +223,14 @@ export const Security = () => {
|
|||||||
id: "adminKey2Input",
|
id: "adminKey2Input",
|
||||||
label: t("security.tertiaryAdminKey.label"),
|
label: t("security.tertiaryAdminKey.label"),
|
||||||
description: t("security.tertiaryAdminKey.description"),
|
description: t("security.tertiaryAdminKey.description"),
|
||||||
validationText: hasFieldError("adminKey2")
|
bits,
|
||||||
? getErrorMessage("adminKey2")
|
devicePSKBitCount: 32,
|
||||||
: "",
|
hide: true,
|
||||||
inputChange: (e) => adminKeyInputChangeEvent(e, 2),
|
|
||||||
selectChange: () => {},
|
|
||||||
bits: [
|
|
||||||
{
|
|
||||||
text: t("security.256bit"),
|
|
||||||
value: "32",
|
|
||||||
key: "bit256",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
devicePSKBitCount: state.privateKeyBitCount,
|
|
||||||
hide: !state.adminKeyVisible[2],
|
|
||||||
actionButtons: [],
|
actionButtons: [],
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{ fieldName: "adminChannelEnabled", invert: true },
|
{ fieldName: "adminChannelEnabled", invert: true },
|
||||||
],
|
],
|
||||||
properties: {
|
properties: {
|
||||||
value: state.adminKey[2],
|
|
||||||
showCopyButton: true,
|
showCopyButton: true,
|
||||||
showPasswordToggle: true,
|
showPasswordToggle: true,
|
||||||
},
|
},
|
||||||
@@ -411,25 +240,12 @@ export const Security = () => {
|
|||||||
name: "isManaged",
|
name: "isManaged",
|
||||||
label: t("security.managed.label"),
|
label: t("security.managed.label"),
|
||||||
description: t("security.managed.description"),
|
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",
|
type: "toggle",
|
||||||
name: "adminChannelEnabled",
|
name: "adminChannelEnabled",
|
||||||
label: t("security.adminChannelEnabled.label"),
|
label: t("security.adminChannelEnabled.label"),
|
||||||
description: t("security.adminChannelEnabled.description"),
|
description: t("security.adminChannelEnabled.description"),
|
||||||
inputChange: (e: boolean) =>
|
|
||||||
onToggleChange("adminChannelEnabled", e),
|
|
||||||
properties: {
|
|
||||||
checked: state.adminChannelEnabled,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -442,21 +258,12 @@ export const Security = () => {
|
|||||||
name: "debugLogApiEnabled",
|
name: "debugLogApiEnabled",
|
||||||
label: t("security.enableDebugLogApi.label"),
|
label: t("security.enableDebugLogApi.label"),
|
||||||
description: t("security.enableDebugLogApi.description"),
|
description: t("security.enableDebugLogApi.description"),
|
||||||
inputChange: (e: boolean) =>
|
|
||||||
onToggleChange("debugLogApiEnabled", e),
|
|
||||||
properties: {
|
|
||||||
checked: state.debugLogApiEnabled,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "serialEnabled",
|
name: "serialEnabled",
|
||||||
label: t("security.serialOutputEnabled.label"),
|
label: t("security.serialOutputEnabled.label"),
|
||||||
description: t("security.serialOutputEnabled.description"),
|
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"),
|
title: t("pkiRegenerate.title"),
|
||||||
description: t("pkiRegenerate.description"),
|
description: t("pkiRegenerate.description"),
|
||||||
}}
|
}}
|
||||||
open={state.privateKeyDialogOpen}
|
open={keyState.privateKeyDialogOpen}
|
||||||
onOpenChange={() =>
|
onOpenChange={() =>
|
||||||
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })}
|
setKeyState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
privateKeyDialogOpen: false,
|
||||||
|
}))}
|
||||||
onSubmit={pkiRegenerate}
|
onSubmit={pkiRegenerate}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"];
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
@@ -23,6 +26,8 @@ export const AmbientLighting = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<AmbientLightingValidation>
|
<DynamicForm<AmbientLightingValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={AmbientLightingValidationSchema}
|
||||||
|
formId="ModuleConfig_AmbientLightingConfig"
|
||||||
defaultValues={moduleConfig.ambientLighting}
|
defaultValues={moduleConfig.ambientLighting}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const Audio = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<AudioValidation>
|
<DynamicForm<AudioValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={AudioValidationSchema}
|
||||||
|
formId="ModuleConfig_AudioConfig"
|
||||||
defaultValues={moduleConfig.audio}
|
defaultValues={moduleConfig.audio}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const CannedMessage = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<CannedMessageValidation>
|
<DynamicForm<CannedMessageValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={CannedMessageValidationSchema}
|
||||||
|
formId="ModuleConfig_CannedMessageConfig"
|
||||||
defaultValues={moduleConfig.cannedMessage}
|
defaultValues={moduleConfig.cannedMessage}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
@@ -23,6 +26,8 @@ export const DetectionSensor = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<DetectionSensorValidation>
|
<DynamicForm<DetectionSensorValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={DetectionSensorValidationSchema}
|
||||||
|
formId="ModuleConfig_DetectionSensorConfig"
|
||||||
defaultValues={moduleConfig.detectionSensor}
|
defaultValues={moduleConfig.detectionSensor}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
@@ -96,17 +101,21 @@ export const DetectionSensor = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "select",
|
||||||
name: "detectionTriggeredHigh",
|
name: "detectionTriggerType",
|
||||||
label: t("detectionSensor.detectionTriggeredHigh.label"),
|
label: t("detectionSensor.detectionTriggerType.label"),
|
||||||
description: t(
|
description: t(
|
||||||
"detectionSensor.detectionTriggeredHigh.description",
|
"detectionSensor.detectionTriggerType.description",
|
||||||
),
|
),
|
||||||
disabledBy: [
|
disabledBy: [
|
||||||
{
|
{
|
||||||
fieldName: "enabled",
|
fieldName: "enabled",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
properties: {
|
||||||
|
enumValue: Protobuf.ModuleConfig
|
||||||
|
.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const ExternalNotification = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<ExternalNotificationValidation>
|
<DynamicForm<ExternalNotificationValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={ExternalNotificationValidationSchema}
|
||||||
|
formId="ModuleConfig_ExternalNotificationConfig"
|
||||||
defaultValues={moduleConfig.externalNotification}
|
defaultValues={moduleConfig.externalNotification}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
@@ -29,6 +32,8 @@ export const MQTT = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<MqttValidation>
|
<DynamicForm<MqttValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={MqttValidationSchema}
|
||||||
|
formId="ModuleConfig_MqttConfig"
|
||||||
defaultValues={moduleConfig.mqtt}
|
defaultValues={moduleConfig.mqtt}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
@@ -23,6 +26,8 @@ export const NeighborInfo = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<NeighborInfoValidation>
|
<DynamicForm<NeighborInfoValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={NeighborInfoValidationSchema}
|
||||||
|
formId="ModuleConfig_NeighborInfoConfig"
|
||||||
defaultValues={moduleConfig.neighborInfo}
|
defaultValues={moduleConfig.neighborInfo}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const Paxcounter = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<PaxcounterValidation>
|
<DynamicForm<PaxcounterValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={PaxcounterValidationSchema}
|
||||||
|
formId="ModuleConfig_PaxcounterConfig"
|
||||||
defaultValues={moduleConfig.paxcounter}
|
defaultValues={moduleConfig.paxcounter}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const RangeTest = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<RangeTestValidation>
|
<DynamicForm<RangeTestValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={RangeTestValidationSchema}
|
||||||
|
formId="ModuleConfig_RangeTestConfig"
|
||||||
defaultValues={moduleConfig.rangeTest}
|
defaultValues={moduleConfig.rangeTest}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const Serial = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<SerialValidation>
|
<DynamicForm<SerialValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={SerialValidationSchema}
|
||||||
|
formId="ModuleConfig_SerialConfig"
|
||||||
defaultValues={moduleConfig.serial}
|
defaultValues={moduleConfig.serial}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const StoreForward = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<StoreForwardValidation>
|
<DynamicForm<StoreForwardValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={StoreForwardValidationSchema}
|
||||||
|
formId="ModuleConfig_StoreForwardConfig"
|
||||||
defaultValues={moduleConfig.storeForward}
|
defaultValues={moduleConfig.storeForward}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -23,6 +26,8 @@ export const Telemetry = () => {
|
|||||||
return (
|
return (
|
||||||
<DynamicForm<TelemetryValidation>
|
<DynamicForm<TelemetryValidation>
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={TelemetryValidationSchema}
|
||||||
|
formId="ModuleConfig_TelemetryConfig"
|
||||||
defaultValues={moduleConfig.telemetry}
|
defaultValues={moduleConfig.telemetry}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import * as React from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
|
import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -27,9 +26,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
|||||||
actionButtons: ActionButton[];
|
actionButtons: ActionButton[];
|
||||||
bits?: { text: string; value: string; key: string }[];
|
bits?: { text: string; value: string; key: string }[];
|
||||||
selectChange: (event: string) => void;
|
selectChange: (event: string) => void;
|
||||||
inputChange: (
|
inputChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||||
event: React.ChangeEventHandler<HTMLInputElement> | undefined,
|
|
||||||
) => void;
|
|
||||||
showPasswordToggle?: boolean;
|
showPasswordToggle?: boolean;
|
||||||
showCopyButton?: boolean;
|
showCopyButton?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -57,10 +54,10 @@ const Generator = (
|
|||||||
...props
|
...props
|
||||||
}: GeneratorProps,
|
}: GeneratorProps,
|
||||||
) => {
|
) => {
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Invokes onChange event on the input element when the value changes from the parent component
|
// Invokes onChange event on the input element when the value changes from the parent component
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inputRef.current) return;
|
if (!inputRef.current) return;
|
||||||
const setValue = Object.getOwnPropertyDescriptor(
|
const setValue = Object.getOwnPropertyDescriptor(
|
||||||
HTMLInputElement.prototype,
|
HTMLInputElement.prototype,
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type DotPath = { [key: string]: unknown } | unknown[];
|
||||||
|
|
||||||
|
export const dotPaths = <T extends DotPath>(
|
||||||
|
obj: T,
|
||||||
|
prefix = "",
|
||||||
|
): string[] => {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.flatMap((v, i) =>
|
||||||
|
v && typeof v === "object"
|
||||||
|
? dotPaths(v as DotPath, `${prefix}${i}.`)
|
||||||
|
: [`${prefix}${i}`]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.entries(obj).flatMap(([k, v]) =>
|
||||||
|
v && typeof v === "object"
|
||||||
|
? dotPaths(v as DotPath, `${prefix}${k}.`)
|
||||||
|
: [`${prefix}${k}`]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -69,5 +69,34 @@
|
|||||||
},
|
},
|
||||||
"nodeUnknownPrefix": "!",
|
"nodeUnknownPrefix": "!",
|
||||||
"unset": "UNSET",
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,11 +68,6 @@
|
|||||||
"pin": {
|
"pin": {
|
||||||
"description": "Pin to use when pairing",
|
"description": "Pin to use when pairing",
|
||||||
"label": "Pin"
|
"label": "Pin"
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"pinCannotStartWithZero": "Bluetooth Pin cannot start with 0",
|
|
||||||
"pinMustBeSixDigits": "Pin must be 6 digits",
|
|
||||||
"pinRequired": "Bluetooth Pin is required"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
@@ -428,15 +423,6 @@
|
|||||||
"loggingSettings": {
|
"loggingSettings": {
|
||||||
"description": "Settings for Logging",
|
"description": "Settings for Logging",
|
||||||
"label": "Logging Settings"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,9 +144,9 @@
|
|||||||
"label": "Monitor Pin",
|
"label": "Monitor Pin",
|
||||||
"description": "The GPIO pin to monitor for state changes"
|
"description": "The GPIO pin to monitor for state changes"
|
||||||
},
|
},
|
||||||
"detectionTriggeredHigh": {
|
"detectionTriggerType": {
|
||||||
"label": "Detection Triggered High",
|
"label": "Detection Triggered Type",
|
||||||
"description": "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)"
|
"description": "The type of trigger event to be used"
|
||||||
},
|
},
|
||||||
"usePullup": {
|
"usePullup": {
|
||||||
"label": "Use Pullup",
|
"label": "Use Pullup",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-46
@@ -1,51 +1,38 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import {
|
import { makePskHelpers } from "./pskSchema.ts";
|
||||||
IsBoolean,
|
import { validateMaxByteLength } from "@core/utils/string.ts";
|
||||||
IsEnum,
|
|
||||||
IsInt,
|
|
||||||
IsNumber,
|
|
||||||
IsString,
|
|
||||||
Length,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export class ChannelValidation
|
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role);
|
||||||
implements Omit<Protobuf.Channel.Channel, keyof Message | "settings"> {
|
|
||||||
@IsNumber()
|
|
||||||
index: number;
|
|
||||||
|
|
||||||
settings: Channel_SettingsValidation;
|
const moduleSettingsSchema = z.object({
|
||||||
|
positionPrecision: z.union([
|
||||||
|
z.literal(0),
|
||||||
|
z.coerce.number().int().min(10).max(19),
|
||||||
|
z.literal(32),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
@IsEnum(Protobuf.Channel.Channel_Role)
|
export function makeChannelSchema(allowedBytes: number) {
|
||||||
role: Protobuf.Channel.Channel_Role;
|
const { stringSchema } = makePskHelpers([allowedBytes]);
|
||||||
}
|
|
||||||
|
const ChannelSettingsSchema = z.object({
|
||||||
export class Channel_SettingsValidation
|
channelNum: z.coerce.number().int().min(0).max(7),
|
||||||
implements Omit<Protobuf.Channel.ChannelSettings, keyof Message | "psk"> {
|
psk: stringSchema(false),
|
||||||
@IsNumber()
|
name: z.string()
|
||||||
channelNum: number;
|
.refine(
|
||||||
|
(s) => validateMaxByteLength(s, 12).isValid,
|
||||||
@IsString()
|
{ message: "formValidation.tooBig.bytes", params: { maximum: 12 } },
|
||||||
psk: string;
|
),
|
||||||
|
id: z.coerce.number().int(),
|
||||||
@Length(0, 11)
|
uplinkEnabled: z.boolean(),
|
||||||
name: string;
|
downlinkEnabled: z.boolean(),
|
||||||
|
moduleSettings: moduleSettingsSchema,
|
||||||
@IsInt()
|
});
|
||||||
id: number;
|
|
||||||
|
return z.object({
|
||||||
@IsBoolean()
|
index: z.coerce.number(),
|
||||||
uplinkEnabled: boolean;
|
settings: ChannelSettingsSchema,
|
||||||
|
role: RoleEnum,
|
||||||
@IsBoolean()
|
});
|
||||||
downlinkEnabled: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
positionEnabled: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
preciseLocation: boolean;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
positionPrecision: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class BluetoothValidation implements
|
const PairingModeEnum = z.enum(
|
||||||
Omit<
|
Protobuf.Config.Config_BluetoothConfig_PairingMode,
|
||||||
Protobuf.Config.Config_BluetoothConfig,
|
);
|
||||||
keyof Message | "deviceLoggingEnabled"
|
|
||||||
> {
|
|
||||||
@IsBoolean()
|
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
@IsEnum(Protobuf.Config.Config_BluetoothConfig_PairingMode)
|
export const BluetoothValidationSchema = z.object({
|
||||||
mode: Protobuf.Config.Config_BluetoothConfig_PairingMode;
|
enabled: z.boolean(),
|
||||||
|
mode: PairingModeEnum,
|
||||||
|
fixedPin: z.coerce.number().int().min(100000).max(999999),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type BluetoothValidation = z.infer<typeof BluetoothValidationSchema>;
|
||||||
fixedPin: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +1,25 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsEnum, IsInt, IsString } from "class-validator";
|
|
||||||
|
|
||||||
export class DeviceValidation
|
const RoleEnum = z.enum(
|
||||||
implements Omit<Protobuf.Config.Config_DeviceConfig, keyof Message> {
|
Protobuf.Config.Config_DeviceConfig_Role,
|
||||||
@IsEnum(Protobuf.Config.Config_DeviceConfig_Role)
|
);
|
||||||
role: Protobuf.Config.Config_DeviceConfig_Role;
|
const RebroadcastModeEnum = z.enum(
|
||||||
|
Protobuf.Config.Config_DeviceConfig_RebroadcastMode,
|
||||||
|
);
|
||||||
|
|
||||||
@IsBoolean()
|
export const DeviceValidationSchema = z.object({
|
||||||
serialEnabled: boolean;
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
@IsBoolean()
|
export type DeviceValidation = z.infer<typeof DeviceValidationSchema>;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,39 +1,35 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class DisplayValidation
|
const GpsCoordinateEnum = z.enum(
|
||||||
implements Omit<Protobuf.Config.Config_DisplayConfig, keyof Message> {
|
Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat,
|
||||||
@IsInt()
|
);
|
||||||
screenOnSecs: number;
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
@IsEnum(Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat)
|
export const DisplayValidationSchema = z.object({
|
||||||
gpsFormat: Protobuf.Config.Config_DisplayConfig_GpsCoordinateFormat;
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type DisplayValidation = z.infer<typeof DisplayValidationSchema>;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,65 +1,31 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
|
|
||||||
|
|
||||||
export class LoRaValidation
|
const ModemPresetEnum = z.enum(
|
||||||
implements
|
Protobuf.Config.Config_LoRaConfig_ModemPreset,
|
||||||
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled"> {
|
);
|
||||||
@IsBoolean()
|
const RegionCodeEnum = z.enum(
|
||||||
usePreset: boolean;
|
Protobuf.Config.Config_LoRaConfig_RegionCode,
|
||||||
|
);
|
||||||
|
|
||||||
@IsEnum(Protobuf.Config.Config_LoRaConfig_ModemPreset)
|
export const LoRaValidationSchema = z.object({
|
||||||
modemPreset: Protobuf.Config.Config_LoRaConfig_ModemPreset;
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type LoRaValidation = z.infer<typeof LoRaValidationSchema>;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
const AddressModeEnum = z.nativeEnum(
|
const AddressModeEnum = z.enum(
|
||||||
Protobuf.Config.Config_NetworkConfig_AddressMode,
|
Protobuf.Config.Config_NetworkConfig_AddressMode,
|
||||||
);
|
);
|
||||||
const ProtocolFlagsEnum = z.nativeEnum(
|
const ProtocolFlagsEnum = z.enum(
|
||||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const NetworkValidationIpV4ConfigSchema = z.object({
|
export const NetworkValidationIpV4ConfigSchema = z.object({
|
||||||
ip: z.string().ip(),
|
ip: z.ipv4(),
|
||||||
gateway: z.string().ip(),
|
gateway: z.ipv4(),
|
||||||
subnet: z.string().ip(),
|
subnet: z.ipv4(),
|
||||||
dns: z.string().ip(),
|
dns: z.ipv4(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NetworkValidationSchema = z.object({
|
export const NetworkValidationSchema = z.object({
|
||||||
wifiEnabled: z.boolean(),
|
wifiEnabled: z.boolean(),
|
||||||
wifiSsid: z.string().min(0).max(33).optional(),
|
wifiSsid: z.string().max(33),
|
||||||
wifiPsk: z.string().min(0).max(64).optional(),
|
wifiPsk: z.string().max(64),
|
||||||
ntpServer: z.string().min(2).max(30),
|
ntpServer: z.string().min(2).max(33),
|
||||||
ethEnabled: z.boolean(),
|
ethEnabled: z.boolean(),
|
||||||
addressMode: AddressModeEnum,
|
addressMode: AddressModeEnum,
|
||||||
ipv4Config: NetworkValidationIpV4ConfigSchema.optional(),
|
ipv4Config: NetworkValidationIpV4ConfigSchema,
|
||||||
enabledProtocols: ProtocolFlagsEnum,
|
enabledProtocols: ProtocolFlagsEnum,
|
||||||
rsyslogServer: z.string(),
|
rsyslogServer: z.string().max(33),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
|
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
|
||||||
|
|||||||
@@ -1,44 +1,22 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
const DeprecatedPositionValidationFields = ["gpsEnabled", "gpsAttemptTime"];
|
const GpsModeEnum = z.enum(
|
||||||
|
Protobuf.Config.Config_PositionConfig_GpsMode,
|
||||||
|
);
|
||||||
|
|
||||||
export class PositionValidation implements
|
export const PositionValidationSchema = z.object({
|
||||||
Omit<
|
positionBroadcastSecs: z.coerce.number().int().min(0),
|
||||||
Protobuf.Config.Config_PositionConfig,
|
positionBroadcastSmartEnabled: z.boolean(),
|
||||||
keyof Message | (typeof DeprecatedPositionValidationFields)[number]
|
fixedPosition: z.boolean(),
|
||||||
> {
|
gpsUpdateInterval: z.coerce.number().int().min(0),
|
||||||
@IsInt()
|
positionFlags: z.coerce.number().int().min(0),
|
||||||
positionBroadcastSecs: number;
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
@IsBoolean()
|
export type PositionValidation = z.infer<typeof PositionValidationSchema>;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +1,14 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
|
|
||||||
|
|
||||||
export class PowerValidation implements
|
export const PowerValidationSchema = z.object({
|
||||||
Omit<
|
isPowerSaving: z.boolean(),
|
||||||
Protobuf.Config.Config_PowerConfig,
|
onBatteryShutdownAfterSecs: z.coerce.number().int().min(0),
|
||||||
keyof Message | "powermonEnables"
|
adcMultiplierOverride: z.coerce.number().min(0).max(4),
|
||||||
> {
|
waitBluetoothSecs: z.coerce.number().int().min(0),
|
||||||
@IsBoolean()
|
sdsSecs: z.coerce.number().int().min(0),
|
||||||
isPowerSaving: boolean;
|
lsSecs: z.coerce.number().int().min(0),
|
||||||
|
minWakeSecs: z.coerce.number().int().min(0),
|
||||||
|
deviceBatteryInaAddress: z.coerce.number().int().min(0),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type PowerValidation = z.infer<typeof PowerValidationSchema>;
|
||||||
onBatteryShutdownAfterSecs: number;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@Min(2)
|
|
||||||
@Max(4)
|
|
||||||
adcMultiplierOverride: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
waitBluetoothSecs: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
sdsSecs: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
lsSecs: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
minWakeSecs: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
deviceBatteryInaAddress: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +1,47 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z, ZodType } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
import { makePskHelpers } from "./../pskSchema.ts";
|
||||||
import { IsBoolean, IsString } from "class-validator";
|
|
||||||
|
|
||||||
export class SecurityValidation implements
|
const {
|
||||||
Omit<
|
stringSchema,
|
||||||
Protobuf.Config.Config_SecurityConfig,
|
bytesSchema,
|
||||||
| keyof Message
|
isValidKey,
|
||||||
| "adminKey"
|
} = makePskHelpers([32]); // 256-bit
|
||||||
| "privateKey"
|
|
||||||
| "publicKey"
|
|
||||||
> {
|
|
||||||
@IsBoolean()
|
|
||||||
adminChannelEnabled: boolean;
|
|
||||||
|
|
||||||
@IsString()
|
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged";
|
||||||
adminKey: [string, string, string];
|
|
||||||
|
|
||||||
@IsBoolean()
|
function makeSecuritySchema<KeyT>(
|
||||||
bluetoothLoggingEnabled: boolean;
|
keyMaker: (optional: boolean) => ZodType<KeyT>,
|
||||||
|
) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
isManaged: z.boolean(),
|
||||||
|
adminChannelEnabled: z.boolean(),
|
||||||
|
debugLogApiEnabled: z.boolean(),
|
||||||
|
serialEnabled: z.boolean(),
|
||||||
|
|
||||||
@IsBoolean()
|
privateKey: keyMaker(false),
|
||||||
debugLogApiEnabled: boolean;
|
publicKey: keyMaker(false),
|
||||||
|
adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]),
|
||||||
@IsBoolean()
|
})
|
||||||
isManaged: boolean;
|
.check((ctx) => {
|
||||||
|
if (ctx.value.isManaged) {
|
||||||
@IsString()
|
const hasAdmin = ctx.value.adminKey.some(isValidKey);
|
||||||
privateKey: string;
|
if (!hasAdmin) {
|
||||||
|
for (const path of [["isManaged"], ["adminKey", 0]] as const) {
|
||||||
@IsString()
|
ctx.issues.push({
|
||||||
publicKey: string;
|
code: "custom",
|
||||||
|
message: isManagedRequiredMsg,
|
||||||
@IsBoolean()
|
path: [...path],
|
||||||
serialEnabled: boolean;
|
input: ctx.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RawSecuritySchema = makeSecuritySchema(stringSchema);
|
||||||
|
export type RawSecurity = z.infer<typeof RawSecuritySchema>;
|
||||||
|
|
||||||
|
export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema);
|
||||||
|
export type ParsedSecurity = z.infer<typeof ParsedSecuritySchema>;
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class AmbientLightingValidation implements
|
export const AmbientLightingValidationSchema = z.object({
|
||||||
Omit<
|
ledState: z.boolean(),
|
||||||
Protobuf.ModuleConfig.ModuleConfig_AmbientLightingConfig,
|
current: z.coerce.number().int().min(0),
|
||||||
keyof Message
|
red: z.coerce.number().int().min(0).max(255),
|
||||||
> {
|
green: z.coerce.number().int().min(0).max(255),
|
||||||
@IsBoolean()
|
blue: z.coerce.number().int().min(0).max(255),
|
||||||
ledState: boolean;
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type AmbientLightingValidation = z.infer<
|
||||||
current: number;
|
typeof AmbientLightingValidationSchema
|
||||||
|
>;
|
||||||
@IsInt()
|
|
||||||
red: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
green: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
blue: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class AudioValidation
|
const Audio_BaudEnum = z.enum(
|
||||||
implements
|
Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud,
|
||||||
Omit<Protobuf.ModuleConfig.ModuleConfig_AudioConfig, keyof Message> {
|
);
|
||||||
@IsBoolean()
|
|
||||||
codec2Enabled: boolean;
|
|
||||||
|
|
||||||
@IsInt()
|
export const AudioValidationSchema = z.object({
|
||||||
pttPin: number;
|
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)
|
export type AudioValidation = z.infer<typeof AudioValidationSchema>;
|
||||||
bitrate: Protobuf.ModuleConfig.ModuleConfig_AudioConfig_Audio_Baud;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
i2sWs: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
i2sSd: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
i2sDin: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
i2sSck: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,45 +1,24 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsEnum, IsInt, Length } from "class-validator";
|
|
||||||
|
|
||||||
export class CannedMessageValidation implements
|
const InputEventCharEnum = z.enum(
|
||||||
Omit<
|
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig_InputEventChar,
|
||||||
Protobuf.ModuleConfig.ModuleConfig_CannedMessageConfig,
|
);
|
||||||
keyof Message
|
|
||||||
> {
|
|
||||||
@IsBoolean()
|
|
||||||
rotary1Enabled: boolean;
|
|
||||||
|
|
||||||
@IsInt()
|
export const CannedMessageValidationSchema = z.object({
|
||||||
inputbrokerPinA: number;
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type CannedMessageValidation = z.infer<
|
||||||
inputbrokerPinB: number;
|
typeof CannedMessageValidationSchema
|
||||||
|
>;
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsInt, Length } from "class-validator";
|
|
||||||
|
|
||||||
export class DetectionSensorValidation implements
|
const detectionTriggerTypeEnum = z.enum(
|
||||||
Omit<
|
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig_TriggerType,
|
||||||
Protobuf.ModuleConfig.ModuleConfig_DetectionSensorConfig,
|
);
|
||||||
keyof Message
|
|
||||||
> {
|
|
||||||
@IsBoolean()
|
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
@IsInt()
|
export const DetectionSensorValidationSchema = z.object({
|
||||||
minimumBroadcastSecs: number;
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type DetectionSensorValidation = z.infer<
|
||||||
stateBroadcastSecs: number;
|
typeof DetectionSensorValidationSchema
|
||||||
|
>;
|
||||||
@IsBoolean()
|
|
||||||
sendBell: boolean;
|
|
||||||
|
|
||||||
@Length(0, 20)
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
monitorPin: number;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
detectionTriggeredHigh: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
usePullup: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,54 +1,23 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class ExternalNotificationValidation implements
|
export const ExternalNotificationValidationSchema = z.object({
|
||||||
Omit<
|
enabled: z.boolean(),
|
||||||
Protobuf.ModuleConfig.ModuleConfig_ExternalNotificationConfig,
|
outputMs: z.coerce.number().int().min(0),
|
||||||
keyof Message
|
output: z.coerce.number().int().min(0),
|
||||||
> {
|
outputVibra: z.coerce.number().int().min(0),
|
||||||
@IsBoolean()
|
outputBuzzer: z.coerce.number().int().min(0),
|
||||||
enabled: boolean;
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type ExternalNotificationValidation = z.infer<
|
||||||
outputMs: number;
|
typeof ExternalNotificationValidationSchema
|
||||||
|
>;
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,59 +1,22 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import {
|
|
||||||
IsBoolean,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Length,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export class MqttValidation implements
|
export const MqttValidationMapReportSettingsSchema = z.object({
|
||||||
Omit<
|
publishIntervalSecs: z.number().optional(),
|
||||||
Protobuf.ModuleConfig.ModuleConfig_MQTTConfig,
|
positionPrecision: z.number().optional(),
|
||||||
keyof Message | "mapReportSettings"
|
});
|
||||||
> {
|
|
||||||
@IsBoolean()
|
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
@Length(0, 30)
|
export const MqttValidationSchema = z.object({
|
||||||
address: string;
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
@Length(0, 30)
|
export type MqttValidation = z.infer<typeof MqttValidationSchema>;
|
||||||
username: string;
|
|
||||||
|
|
||||||
@Length(0, 30)
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
encryptionEnabled: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
jsonEnabled: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
tlsEnabled: boolean;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
root: string;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
proxyToClientEnabled: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
mapReportingEnabled: boolean;
|
|
||||||
|
|
||||||
mapReportSettings: MqttValidationMapReportSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MqttValidationMapReportSettings
|
|
||||||
implements
|
|
||||||
Omit<Protobuf.ModuleConfig.ModuleConfig_MapReportSettings, keyof Message> {
|
|
||||||
@IsNumber()
|
|
||||||
@IsOptional()
|
|
||||||
publishIntervalSecs: number;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
@IsOptional()
|
|
||||||
positionPrecision: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class NeighborInfoValidation
|
export const NeighborInfoValidationSchema = z.object({
|
||||||
implements
|
enabled: z.boolean(),
|
||||||
Omit<Protobuf.ModuleConfig.ModuleConfig_NeighborInfoConfig, keyof Message> {
|
updateInterval: z.coerce.number().int().min(0),
|
||||||
@IsBoolean()
|
});
|
||||||
enabled: boolean;
|
|
||||||
|
|
||||||
@IsInt()
|
export type NeighborInfoValidation = z.infer<
|
||||||
updateInterval: number;
|
typeof NeighborInfoValidationSchema
|
||||||
}
|
>;
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class PaxcounterValidation
|
export const PaxcounterValidationSchema = z.object({
|
||||||
implements
|
enabled: z.boolean(),
|
||||||
Omit<Protobuf.ModuleConfig.ModuleConfig_PaxcounterConfig, keyof Message> {
|
paxcounterUpdateInterval: z.coerce.number().int().min(0),
|
||||||
@IsBoolean()
|
bleThreshold: z.coerce.number().int(),
|
||||||
enabled: boolean;
|
wifiThreshold: z.coerce.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type PaxcounterValidation = z.infer<typeof PaxcounterValidationSchema>;
|
||||||
paxcounterUpdateInterval: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
bleThreshold: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
wifiThreshold: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class RangeTestValidation
|
export const RangeTestValidationSchema = z.object({
|
||||||
implements
|
enabled: z.boolean(),
|
||||||
Omit<Protobuf.ModuleConfig.ModuleConfig_RangeTestConfig, keyof Message> {
|
sender: z.coerce.number().int().min(0),
|
||||||
@IsBoolean()
|
save: z.boolean(),
|
||||||
enabled: boolean;
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type RangeTestValidation = z.infer<typeof RangeTestValidationSchema>;
|
||||||
sender: number;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
save: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,31 +1,22 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class SerialValidation
|
const Serial_BaudEnum = z.enum(
|
||||||
implements
|
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Baud,
|
||||||
Omit<Protobuf.ModuleConfig.ModuleConfig_SerialConfig, keyof Message> {
|
);
|
||||||
@IsBoolean()
|
const Serial_ModeEnum = z.enum(
|
||||||
enabled: boolean;
|
Protobuf.ModuleConfig.ModuleConfig_SerialConfig_Serial_Mode,
|
||||||
|
);
|
||||||
|
|
||||||
@IsBoolean()
|
export const SerialValidationSchema = z.object({
|
||||||
echo: boolean;
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type SerialValidation = z.infer<typeof SerialValidationSchema>;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class StoreForwardValidation implements
|
export const StoreForwardValidationSchema = z.object({
|
||||||
Omit<
|
enabled: z.boolean(),
|
||||||
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
|
heartbeat: z.boolean(),
|
||||||
keyof Message | "isServer"
|
records: z.coerce.number().int().min(0),
|
||||||
> {
|
historyReturnMax: z.coerce.number().int().min(0),
|
||||||
@IsBoolean()
|
historyReturnWindow: z.coerce.number().int().min(0),
|
||||||
enabled: boolean;
|
});
|
||||||
|
|
||||||
@IsBoolean()
|
export type StoreForwardValidation = z.infer<
|
||||||
heartbeat: boolean;
|
typeof StoreForwardValidationSchema
|
||||||
|
>;
|
||||||
@IsInt()
|
|
||||||
records: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
historyReturnMax: number;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
historyReturnWindow: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +1,18 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod/v4";
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { IsBoolean, IsInt } from "class-validator";
|
|
||||||
|
|
||||||
export class TelemetryValidation
|
export const TelemetryValidationSchema = z.object({
|
||||||
implements
|
deviceUpdateInterval: z.coerce.number().int().min(0),
|
||||||
Omit<Protobuf.ModuleConfig.ModuleConfig_TelemetryConfig, keyof Message> {
|
environmentUpdateInterval: z.coerce.number().int().min(0),
|
||||||
@IsInt()
|
environmentMeasurementEnabled: z.boolean(),
|
||||||
deviceUpdateInterval: number;
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
@IsInt()
|
export type TelemetryValidation = z.infer<
|
||||||
environmentUpdateInterval: number;
|
typeof TelemetryValidationSchema
|
||||||
|
>;
|
||||||
@IsBoolean()
|
|
||||||
environmentMeasurementEnabled: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
environmentScreenEnabled: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
environmentDisplayFahrenheit: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
airQualityEnabled: boolean;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
airQualityInterval: number;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
powerMeasurementEnabled: boolean;
|
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
powerUpdateInterval: number;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
powerScreenEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { z, ZodType } from "zod/v4";
|
||||||
|
import { toByteArray } from "base64-js";
|
||||||
|
|
||||||
|
export function makePskHelpers(
|
||||||
|
allowedByteLengths: readonly number[],
|
||||||
|
) {
|
||||||
|
const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | ");
|
||||||
|
const msgs = {
|
||||||
|
format: "formValidation.invalidFormat.key",
|
||||||
|
required: "formValidation.required.key",
|
||||||
|
length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function tryParse(str: string): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
return toByteArray(str);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidString(str: string): boolean {
|
||||||
|
const arr = tryParse(str);
|
||||||
|
return arr !== null &&
|
||||||
|
allowedByteLengths.includes(arr.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidKey(v: unknown): boolean {
|
||||||
|
if (typeof v === "string") return isValidString(v);
|
||||||
|
if (v instanceof Uint8Array) {
|
||||||
|
return allowedByteLengths.includes(v.byteLength);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringSchema = (optional = false) =>
|
||||||
|
z.string()
|
||||||
|
.refine((s) =>
|
||||||
|
optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), {
|
||||||
|
message: msgs.required,
|
||||||
|
})
|
||||||
|
.refine((s) =>
|
||||||
|
s === "" || tryParse(s) !== null, { message: msgs.format })
|
||||||
|
.refine((s) =>
|
||||||
|
s === "" || isValidString(s), {
|
||||||
|
message: msgs.length,
|
||||||
|
params: { bits: bitsLabel },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bytesSchema = (optional = false): ZodType<Uint8Array> =>
|
||||||
|
z.instanceof(Uint8Array)
|
||||||
|
.refine(
|
||||||
|
(arr) =>
|
||||||
|
optional || arr.byteLength !== 0 || allowedByteLengths.includes(0),
|
||||||
|
{ message: msgs.required },
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(arr) => optional || allowedByteLengths.includes(arr.byteLength),
|
||||||
|
{ message: msgs.length, params: { bits: bitsLabel } },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowedByteLengths,
|
||||||
|
msgs,
|
||||||
|
tryParseStringKey: tryParse,
|
||||||
|
isValidKey,
|
||||||
|
stringSchema,
|
||||||
|
bytesSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ZodError, ZodSchema } from "zod";
|
import { ZodError, ZodType } from "zod/v4";
|
||||||
|
|
||||||
export function validateSchema<T>(
|
export function validateSchema<T>(
|
||||||
schema: ZodSchema<T>,
|
schema: ZodType<T>,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
|
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
|
||||||
const result = schema.safeParse(data);
|
const result = schema.safeParse(data);
|
||||||
|
|||||||
Reference in New Issue
Block a user