Browse Source

Config reset work WIP

pull/652/head
philon- 1 year ago
parent
commit
be34dde0c1
  1. 40
      src/components/Form/DynamicForm.tsx
  2. 4
      src/components/Form/FormInput.tsx
  3. 9
      src/components/Form/FormPasswordGenerator.tsx
  4. 15
      src/components/PageComponents/Config/Bluetooth.tsx
  5. 18
      src/components/PageComponents/Config/Device/index.tsx
  6. 14
      src/components/PageComponents/Config/Display.tsx
  7. 14
      src/components/PageComponents/Config/LoRa.tsx
  8. 32
      src/components/PageComponents/Config/Network/index.tsx
  9. 16
      src/components/PageComponents/Config/Position.tsx
  10. 14
      src/components/PageComponents/Config/Power.tsx
  11. 140
      src/components/PageComponents/Config/Security/Security.tsx
  12. 30
      src/components/PageLayout.tsx
  13. 2
      src/components/UI/Generator.tsx
  14. 15
      src/components/UI/Input.tsx
  15. 147
      src/core/stores/deviceStore.ts
  16. 1
      src/i18n/locales/en/common.json
  17. 48
      src/pages/Config/DeviceConfig.tsx
  18. 121
      src/pages/Config/index.tsx
  19. 2
      src/validation/config/network.ts
  20. 2
      src/validation/config/security.ts

40
src/components/Form/DynamicForm.tsx

@ -14,6 +14,7 @@ import {
type Path, type Path,
type SubmitHandler, type SubmitHandler,
useForm, useForm,
type UseFormReturn,
} 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 { ZodType } from "zod/v4";
@ -47,10 +48,13 @@ export interface GenericFormElementProps<T extends FieldValues, Y> {
} }
export interface DynamicFormProps<T extends FieldValues> { export interface DynamicFormProps<T extends FieldValues> {
propMethods?: UseFormReturn<T, T, T>;
onSubmit: SubmitHandler<T>; onSubmit: SubmitHandler<T>;
onFormInit?: DynamicFormFormInit<T>;
submitType?: "onChange" | "onSubmit"; submitType?: "onChange" | "onSubmit";
hasSubmitButton?: boolean; hasSubmitButton?: boolean;
defaultValues?: DefaultValues<T>; defaultValues?: DefaultValues<T>;
values?: T;
fieldGroups: { fieldGroups: {
label: string; label: string;
description: string; description: string;
@ -63,11 +67,18 @@ export interface DynamicFormProps<T extends FieldValues> {
formId?: string; formId?: string;
} }
export type DynamicFormFormInit<T extends FieldValues> = (
methods: UseFormReturn<T, T, T>,
) => void;
export function DynamicForm<T extends FieldValues>({ export function DynamicForm<T extends FieldValues>({
propMethods,
onSubmit, onSubmit,
onFormInit,
submitType = "onChange", submitType = "onChange",
hasSubmitButton, hasSubmitButton,
defaultValues, defaultValues,
values,
fieldGroups, fieldGroups,
validationSchema, validationSchema,
formId, formId,
@ -78,7 +89,9 @@ export function DynamicForm<T extends FieldValues>({
removeError, removeError,
} = useAppStore(); } = useAppStore();
const methods = useForm< let methods = propMethods;
if (!methods) {
methods = useForm<
T T
>({ >({
mode: "onChange", mode: "onChange",
@ -87,9 +100,18 @@ export function DynamicForm<T extends FieldValues>({
? createZodResolver(validationSchema) ? createZodResolver(validationSchema)
: undefined, : undefined,
shouldFocusError: false, shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
values,
}); });
}
const { handleSubmit, control, getValues, formState } = methods; const { handleSubmit, control, getValues, formState } = methods;
useEffect(() => {
if (!propMethods) {
onFormInit?.(methods);
}
}, [onFormInit, propMethods, methods]);
useEffect(() => { useEffect(() => {
const errorKeys = Object.keys(formState.errors); const errorKeys = Object.keys(formState.errors);
if (formId) { if (formId) {
@ -108,7 +130,10 @@ export function DynamicForm<T extends FieldValues>({
} }
} }
}, [formState.errors]); }, [formState.errors]);
console.debug(
`DynamicForm: ${formId} - Form state:`,
formState.isDirty,
);
const isDisabled = ( const isDisabled = (
disabledBy?: DisabledBy<T>[], disabledBy?: DisabledBy<T>[],
disabled?: boolean, disabled?: boolean,
@ -155,20 +180,15 @@ export function DynamicForm<T extends FieldValues>({
label={field.label} label={field.label}
fieldName={field.name} fieldName={field.name}
description={field.description} description={field.description}
valid={validationSchema // keep backwards compat with not updated cfg pages valid={!error}
? !error validationText={error
: field.validationText === undefined ||
field.validationText === ""}
validationText={validationSchema
? (error
? String( ? String(
t([`formValidation.${error.type}`, error.message], { t([`formValidation.${error.type}`, error.message], {
returnObjects: false, returnObjects: false,
...error.params, ...error.params,
}), }),
) )
: "") : ""}
: field.validationText}
> >
<DynamicFormField <DynamicFormField
field={field} field={field}

4
src/components/Form/FormInput.tsx

@ -37,7 +37,8 @@ export function GenericInput<T extends FieldValues>({
fieldLength?.currentValueLength || 0, fieldLength?.currentValueLength || 0,
); );
const { field: controllerField } = useController({ const { field: controllerField, fieldState: { invalid, isDirty } } =
useController({
name: field.name, name: field.name,
control, control,
}); });
@ -78,6 +79,7 @@ export function GenericInput<T extends FieldValues>({
className={field.properties?.className} className={field.properties?.className}
{...restProperties} {...restProperties}
disabled={disabled} disabled={disabled}
variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
/> />
{fieldLength?.showCharacterCount && fieldLength?.max && ( {fieldLength?.showCharacterCount && fieldLength?.max && (

9
src/components/Form/FormPasswordGenerator.tsx

@ -42,7 +42,12 @@ export function PasswordGenerator<T extends FieldValues>({
<Controller <Controller
name={field.name} name={field.name}
control={control} control={control}
render={({ field: { value, onChange, ...rest } }) => ( render={(
{
field: { value, onChange, ...rest },
fieldState: { invalid, isDirty },
},
) => (
<Generator <Generator
type={field.hide && !isVisible ? "password" : "text"} type={field.hide && !isVisible ? "password" : "text"}
id={field.id} id={field.id}
@ -54,7 +59,7 @@ export function PasswordGenerator<T extends FieldValues>({
}} }}
selectChange={field.selectChange ?? (() => {})} selectChange={field.selectChange ?? (() => {})}
value={value} value={value}
variant={field.validationText ? "invalid" : "default"} variant={invalid ? "invalid" : isDirty ? "dirty" : "default"}
actionButtons={field.actionButtons} actionButtons={field.actionButtons}
showPasswordToggle={field.showPasswordToggle} showPasswordToggle={field.showPasswordToggle}
showCopyButton={field.showCopyButton} showCopyButton={field.showCopyButton}

15
src/components/PageComponents/Config/Bluetooth.tsx

@ -3,13 +3,18 @@ import {
BluetoothValidationSchema, BluetoothValidationSchema,
} from "@app/validation/config/bluetooth.ts"; } 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,
type DynamicFormFormInit,
} 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface BluetoothConfigProps {
export const Bluetooth = () => { onFormInit: DynamicFormFormInit<BluetoothValidation>;
const { config, setWorkingConfig } = useDevice(); }
export const Bluetooth = ({ onFormInit }: BluetoothConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const onSubmit = (data: BluetoothValidation) => { const onSubmit = (data: BluetoothValidation) => {
@ -26,9 +31,11 @@ export const Bluetooth = () => {
return ( return (
<DynamicForm<BluetoothValidation> <DynamicForm<BluetoothValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={BluetoothValidationSchema} validationSchema={BluetoothValidationSchema}
formId="Config_BluetoothConfig" formId="Config_BluetoothConfig"
defaultValues={config.bluetooth} defaultValues={config.bluetooth}
values={getEffectiveConfig("bluetooth")}
fieldGroups={[ fieldGroups={[
{ {
label: t("bluetooth.title"), label: t("bluetooth.title"),

18
src/components/PageComponents/Config/Device/index.tsx

@ -3,14 +3,20 @@ import {
DeviceValidationSchema, DeviceValidationSchema,
} from "@app/validation/config/device.ts"; } 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,
type DynamicFormFormInit,
} 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 { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Device = () => { interface DeviceConfigProps {
const { config, setWorkingConfig } = useDevice(); onFormInit: DynamicFormFormInit<DeviceValidation>;
}
export const Device = ({ onFormInit }: DeviceConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const { validateRoleSelection } = useUnsafeRolesDialog(); const { validateRoleSelection } = useUnsafeRolesDialog();
@ -24,12 +30,15 @@ export const Device = () => {
}), }),
); );
}; };
return ( return (
<DynamicForm<DeviceValidation> <DynamicForm<DeviceValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DeviceValidationSchema} validationSchema={DeviceValidationSchema}
formId="Config_DeviceConfig" formId="Config_DeviceConfig"
defaultValues={config.device} defaultValues={config.device}
values={getEffectiveConfig("device")}
fieldGroups={[ fieldGroups={[
{ {
label: t("device.title"), label: t("device.title"),
@ -97,7 +106,8 @@ export const Device = () => {
properties: { properties: {
fieldLength: { fieldLength: {
max: 64, max: 64,
currentValueLength: config.device?.tzdef?.length, currentValueLength: getEffectiveConfig("device")?.tzdef
?.length,
showCharacterCount: true, showCharacterCount: true,
}, },
}, },

14
src/components/PageComponents/Config/Display.tsx

@ -3,13 +3,19 @@ import {
DisplayValidationSchema, DisplayValidationSchema,
} from "@app/validation/config/display.ts"; } 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,
type DynamicFormFormInit,
} 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Display = () => { interface DisplayConfigProps {
const { config, setWorkingConfig } = useDevice(); onFormInit: DynamicFormFormInit<DisplayValidation>;
}
export const Display = ({ onFormInit }: DisplayConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const onSubmit = (data: DisplayValidation) => { const onSubmit = (data: DisplayValidation) => {
@ -26,9 +32,11 @@ export const Display = () => {
return ( return (
<DynamicForm<DisplayValidation> <DynamicForm<DisplayValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={DisplayValidationSchema} validationSchema={DisplayValidationSchema}
formId="Config_DisplayConfig" formId="Config_DisplayConfig"
defaultValues={config.display} defaultValues={config.display}
values={getEffectiveConfig("display")}
fieldGroups={[ fieldGroups={[
{ {
label: t("display.title"), label: t("display.title"),

14
src/components/PageComponents/Config/LoRa.tsx

@ -3,13 +3,19 @@ import {
LoRaValidationSchema, LoRaValidationSchema,
} from "@app/validation/config/lora.ts"; } 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,
type DynamicFormFormInit,
} 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const LoRa = () => { interface LoRaConfigProps {
const { config, setWorkingConfig } = useDevice(); onFormInit: DynamicFormFormInit<LoRaValidation>;
}
export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const onSubmit = (data: LoRaValidation) => { const onSubmit = (data: LoRaValidation) => {
@ -26,9 +32,11 @@ export const LoRa = () => {
return ( return (
<DynamicForm<LoRaValidation> <DynamicForm<LoRaValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema} validationSchema={LoRaValidationSchema}
formId="Config_LoRaConfig" formId="Config_LoRaConfig"
defaultValues={config.lora} defaultValues={config.lora}
values={getEffectiveConfig("lora")}
fieldGroups={[ fieldGroups={[
{ {
label: t("lora.title"), label: t("lora.title"),

32
src/components/PageComponents/Config/Network/index.tsx

@ -3,7 +3,10 @@ import {
NetworkValidationSchema, NetworkValidationSchema,
} from "@app/validation/config/network.ts"; } from "@app/validation/config/network.ts";
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import {
DynamicForm,
type DynamicFormFormInit,
} from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { import {
convertIntToIpAddress, convertIntToIpAddress,
@ -12,9 +15,15 @@ import {
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Network = () => { interface NetworkConfigProps {
const { config, setWorkingConfig } = useDevice(); onFormInit: DynamicFormFormInit<NetworkValidation>;
}
export const Network = ({ onFormInit }: NetworkConfigProps) => {
const { config, setWorkingConfig, getEffectiveConfig } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const networkConfig = getEffectiveConfig("network");
const onSubmit = (data: NetworkValidation) => { const onSubmit = (data: NetworkValidation) => {
setWorkingConfig( setWorkingConfig(
create(Protobuf.Config.ConfigSchema, { create(Protobuf.Config.ConfigSchema, {
@ -36,10 +45,10 @@ export const Network = () => {
}), }),
); );
}; };
return ( return (
<DynamicForm<NetworkValidation> <DynamicForm<NetworkValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={NetworkValidationSchema} validationSchema={NetworkValidationSchema}
formId="Config_NetworkConfig" formId="Config_NetworkConfig"
defaultValues={{ defaultValues={{
@ -57,6 +66,21 @@ export const Network = () => {
enabledProtocols: config.network?.enabledProtocols ?? enabledProtocols: config.network?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
}} }}
values={{
...networkConfig,
ipv4Config: {
ip: convertIntToIpAddress(networkConfig?.ipv4Config?.ip ?? 0),
gateway: convertIntToIpAddress(
networkConfig?.ipv4Config?.gateway ?? 0,
),
subnet: convertIntToIpAddress(
networkConfig?.ipv4Config?.subnet ?? 0,
),
dns: convertIntToIpAddress(networkConfig?.ipv4Config?.dns ?? 0),
},
enabledProtocols: networkConfig?.enabledProtocols ??
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
} as NetworkValidation}
fieldGroups={[ fieldGroups={[
{ {
label: t("network.title"), label: t("network.title"),

16
src/components/PageComponents/Config/Position.tsx

@ -7,16 +7,22 @@ import {
PositionValidationSchema, PositionValidationSchema,
} from "@app/validation/config/position.ts"; } 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,
type DynamicFormFormInit,
} 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 { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Position = () => { interface PositionConfigProps {
const { config, setWorkingConfig } = useDevice(); onFormInit: DynamicFormFormInit<PositionValidation>;
}
export const Position = ({ onFormInit }: PositionConfigProps) => {
const { setWorkingConfig, config, getEffectiveConfig } = useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags( const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
config?.position?.positionFlags ?? 0, getEffectiveConfig("position")?.positionFlags ?? 0,
); );
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
@ -44,9 +50,11 @@ export const Position = () => {
data.positionFlags = flagsValue; data.positionFlags = flagsValue;
return onSubmit(data); return onSubmit(data);
}} }}
onFormInit={onFormInit}
validationSchema={PositionValidationSchema} validationSchema={PositionValidationSchema}
formId="Config_PositionConfig" formId="Config_PositionConfig"
defaultValues={config.position} defaultValues={config.position}
values={getEffectiveConfig("position")}
fieldGroups={[ fieldGroups={[
{ {
label: t("position.title"), label: t("position.title"),

14
src/components/PageComponents/Config/Power.tsx

@ -3,13 +3,19 @@ import {
PowerValidationSchema, PowerValidationSchema,
} from "@app/validation/config/power.ts"; } 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,
type DynamicFormFormInit,
} 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export const Power = () => { interface PowerConfigProps {
const { config, setWorkingConfig } = useDevice(); onFormInit: DynamicFormFormInit<PowerValidation>;
}
export const Power = ({ onFormInit }: PowerConfigProps) => {
const { setWorkingConfig, config, getEffectiveConfig } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const onSubmit = (data: PowerValidation) => { const onSubmit = (data: PowerValidation) => {
@ -26,9 +32,11 @@ export const Power = () => {
return ( return (
<DynamicForm<PowerValidation> <DynamicForm<PowerValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={PowerValidationSchema} validationSchema={PowerValidationSchema}
formId="Config_PowerConfig" formId="Config_PowerConfig"
defaultValues={config.power} defaultValues={config.power}
values={getEffectiveConfig("power")}
fieldGroups={[ fieldGroups={[
{ {
label: t("power.powerConfigSettings.label"), label: t("power.powerConfigSettings.label"),

140
src/components/PageComponents/Config/Security/Security.tsx

@ -1,5 +1,8 @@
import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"; import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx";
import { DynamicForm } from "@components/Form/DynamicForm.tsx"; import {
DynamicForm,
type DynamicFormFormInit,
} 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 { import {
@ -7,37 +10,75 @@ import {
type RawSecurity, type RawSecurity,
RawSecuritySchema, RawSecuritySchema,
} from "@app/validation/config/security.ts"; } from "@app/validation/config/security.ts";
import { useState } from "react"; import { useEffect, 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 { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type DefaultValues, useForm } from "react-hook-form";
import { createZodResolver } from "@components/Form/createZodResolver.ts";
interface SecurityConfigProps {
onFormInit: DynamicFormFormInit<RawSecurity>;
}
export const Security = ({ onFormInit }: SecurityConfigProps) => {
const {
setWorkingConfig,
setDialogOpen,
getEffectiveConfig,
} = useDevice();
type KeyState = {
publicKey: string;
privateKey: string;
privateKeyDialogOpen: boolean;
};
export const Security = () => {
const { config, setWorkingConfig, setDialogOpen } = useDevice();
const { removeError } = useAppStore(); const { removeError } = useAppStore();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const [keyState, setKeyState] = useState<KeyState>(() => ({ const securityConfig = getEffectiveConfig("security");
publicKey: fromByteArray(config?.security?.publicKey ?? new Uint8Array(0)), const defaultValues = {
...securityConfig,
...{
privateKey: fromByteArray( privateKey: fromByteArray(
config?.security?.privateKey ?? new Uint8Array(0), securityConfig?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(
securityConfig?.publicKey ?? new Uint8Array(0),
),
adminKey: [
fromByteArray(
securityConfig?.adminKey?.at(0) ?? new Uint8Array(0),
),
fromByteArray(
securityConfig?.adminKey?.at(1) ?? new Uint8Array(0),
), ),
privateKeyDialogOpen: false, fromByteArray(
})); securityConfig?.adminKey?.at(2) ?? new Uint8Array(0),
),
],
},
};
const formMethods = useForm<RawSecurity>({
mode: "onChange",
defaultValues: defaultValues as DefaultValues<RawSecurity>,
resolver: createZodResolver(RawSecuritySchema),
shouldFocusError: false,
resetOptions: { keepDefaultValues: true },
});
const { setValue, formState } = formMethods;
useEffect(() => {
onFormInit?.(formMethods);
}, [onFormInit, formMethods]);
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(
false,
);
const onSubmit = (data: RawSecurity) => { const onSubmit = (data: RawSecurity) => {
if (!formState.isReady) return;
const payload: ParsedSecurity = { const payload: ParsedSecurity = {
...data, ...data,
privateKey: toByteArray(keyState.privateKey), privateKey: toByteArray(data.privateKey),
publicKey: toByteArray(keyState.publicKey), publicKey: toByteArray(data.publicKey),
adminKey: [ adminKey: [
toByteArray(data.adminKey.at(0) ?? ""), toByteArray(data.adminKey.at(0) ?? ""),
toByteArray(data.adminKey.at(1) ?? ""), toByteArray(data.adminKey.at(1) ?? ""),
@ -54,18 +95,10 @@ export const Security = () => {
}), }),
); );
}; };
const pkiRegenerate = () => { const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey(); const privateKey = getX25519PrivateKey();
updatePublicKey(fromByteArray(privateKey)); updatePublicKey(fromByteArray(privateKey));
setKeyState((prev) => ({
...prev,
privateKey: fromByteArray(privateKey),
privateKeyDialogOpen: false,
}));
removeError("privateKey");
}; };
const updatePublicKey = (privateKey: string) => { const updatePublicKey = (privateKey: string) => {
@ -73,18 +106,14 @@ export const Security = () => {
const publicKey = fromByteArray( const publicKey = fromByteArray(
getX25519PublicKey(toByteArray(privateKey)), getX25519PublicKey(toByteArray(privateKey)),
); );
setKeyState((prev) => ({ setValue("privateKey", privateKey);
...prev, setValue("publicKey", publicKey);
privateKey: privateKey,
publicKey: publicKey,
}));
removeError("privateKey");
removeError("publicKey"); removeError("publicKey");
setPrivateKeyDialogOpen(false);
} catch (_e) { } catch (_e) {
setKeyState((prev) => ({ setValue("privateKey", privateKey);
...prev,
privateKey: privateKey,
}));
} }
}; };
@ -99,31 +128,9 @@ export const Security = () => {
return ( return (
<> <>
<DynamicForm<RawSecurity> <DynamicForm<RawSecurity>
propMethods={formMethods}
onSubmit={onSubmit} onSubmit={onSubmit}
validationSchema={RawSecuritySchema}
formId="Config_SecurityConfig" formId="Config_SecurityConfig"
defaultValues={{
...config.security,
...{
privateKey: fromByteArray(
config?.security?.privateKey ?? new Uint8Array(0),
),
publicKey: fromByteArray(
config?.security?.publicKey ?? new Uint8Array(0),
),
adminKey: [
fromByteArray(
config?.security?.adminKey.at(0) ?? new Uint8Array(0),
),
fromByteArray(
config?.security?.adminKey.at(1) ?? new Uint8Array(0),
),
fromByteArray(
config?.security?.adminKey.at(2) ?? new Uint8Array(0),
),
],
},
}}
fieldGroups={[ fieldGroups={[
{ {
label: t("security.title"), label: t("security.title"),
@ -144,11 +151,7 @@ export const Security = () => {
actionButtons: [ actionButtons: [
{ {
text: t("button.generate"), text: t("button.generate"),
onClick: () => onClick: () => setPrivateKeyDialogOpen(true),
setKeyState((prev) => ({
...prev,
privateKeyDialogOpen: true,
})),
variant: "success", variant: "success",
}, },
{ {
@ -160,8 +163,6 @@ export const Security = () => {
properties: { properties: {
showCopyButton: true, showCopyButton: true,
showPasswordToggle: true, showPasswordToggle: true,
value: keyState.privateKey,
}, },
}, },
{ {
@ -172,7 +173,6 @@ export const Security = () => {
description: t("security.publicKey.description"), description: t("security.publicKey.description"),
properties: { properties: {
showCopyButton: true, showCopyButton: true,
value: keyState.publicKey,
}, },
}, },
], ],
@ -275,12 +275,8 @@ export const Security = () => {
title: t("pkiRegenerate.title"), title: t("pkiRegenerate.title"),
description: t("pkiRegenerate.description"), description: t("pkiRegenerate.description"),
}} }}
open={keyState.privateKeyDialogOpen} open={privateKeyDialogOpen}
onOpenChange={() => onOpenChange={() => setPrivateKeyDialogOpen(true)}
setKeyState((prev) => ({
...prev,
privateKeyDialogOpen: false,
}))}
onSubmit={pkiRegenerate} onSubmit={pkiRegenerate}
/> />
</> </>

30
src/components/PageLayout.tsx

@ -8,12 +8,14 @@ import { ErrorPage } from "@components/UI/ErrorPage.tsx";
export interface ActionItem { export interface ActionItem {
key: string; key: string;
icon: LucideIcon; icon?: LucideIcon;
iconClasses?: string; iconClasses?: string;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
ariaLabel?: string; ariaLabel?: string;
label?: string;
className?: string;
} }
export interface PageLayoutProps { export interface PageLayoutProps {
@ -69,27 +71,39 @@ export const PageLayout = ({
<span className="text-lg font-medium text-foreground truncate px-2"> <span className="text-lg font-medium text-foreground truncate px-2">
{label} {label}
</span> </span>
<div className="flex items-center space-x-3 md:space-x-4 shrink-0"> <div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
{actions?.map((action) => ( {actions?.map((action) => {
return (
<button <button
key={action.key} key={action.key}
type="button" type="button"
disabled={action.disabled || action.isLoading} disabled={action.disabled || action.isLoading}
className="text-foreground transition-colors hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed" className={cn(
"flex items-center space-x-2 py-2 px-3 rounded-md",
"text-foreground transition-colors hover:text-accent",
"hover:bg-slate-200 disabled:hover:bg-white",
"disabled:opacity-50 disabled:cursor-not-allowed",
action.className,
)}
onClick={action.onClick} onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`} aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled} aria-disabled={action.disabled}
aria-busy={action.isLoading} aria-busy={action.isLoading}
> >
<div className="mr-6"> {action.icon &&
{action.isLoading ? <Spinner size="md" /> : ( (action.isLoading ? <Spinner size="md" /> : (
<action.icon <action.icon
className={cn("h-5 w-5", action.iconClasses)} className={cn("h-5 w-5", action.iconClasses)}
/> />
))}
{action.label && (
<span className="text-sm px-1 pt-0.5">
{action.label}
</span>
)} )}
</div>
</button> </button>
))} );
})}
</div> </div>
</div> </div>
</header> </header>

2
src/components/UI/Generator.tsx

@ -22,7 +22,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
devicePSKBitCount?: number; devicePSKBitCount?: number;
value: string; value: string;
id: string; id: string;
variant: "default" | "invalid"; variant: "default" | "invalid" | "dirty";
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;

15
src/components/UI/Input.tsx

@ -14,6 +14,8 @@ const inputVariants = cva(
default: "border-slate-300 dark:border-slate-500", default: "border-slate-300 dark:border-slate-500",
invalid: invalid:
"border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500", "border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-500",
dirty:
"border-sky-500 dark:border-sky-500 focus:ring-sky-500 dark:focus:ring-sky-500",
}, },
}, },
defaultVariants: { defaultVariants: {
@ -49,6 +51,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className, className,
containerClassName, containerClassName,
variant, variant,
disabled,
type = "text", type = "text",
prefix, prefix,
suffix, suffix,
@ -153,6 +156,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref} ref={ref}
value={value} value={value}
onChange={onChange} onChange={onChange}
disabled={disabled}
{...props} {...props}
/> />
@ -161,6 +165,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<span <span
className={cn( className={cn(
"inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300", "inline-flex items-center border border-l-0 border-slate-300 bg-slate-100/80 px-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300",
variant === "invalid" && "border-red-500 dark:border-red-500",
variant === "dirty" && "border-sky-500 dark:border-sky-500",
!hasActions && "rounded-r-md", !hasActions && "rounded-r-md",
)} )}
> >
@ -171,7 +177,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
{hasActions && ( {hasActions && (
<div <div
className={cn( className={cn(
"flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-700", "flex items-center divide-x divide-slate-300 border border-l-0 border-slate-300 dark:divide-slate-700 dark:border-slate-500",
variant === "invalid" && "border-red-500 dark:border-red-500",
variant === "dirty" && "border-sky-500 dark:border-sky-500",
disabled &&
"border-slate-200 dark:border-slate-700 divide-slate-200",
!hasSuffix && "rounded-r-md", !hasSuffix && "rounded-r-md",
"bg-white dark:bg-slate-800", "bg-white dark:bg-slate-800",
)} )}
@ -181,7 +191,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
key={action.id} key={action.id}
type="button" type="button"
className={cn( className={cn(
"inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 hover:rounded-md dark:hover:rounded-md", "inline-flex h-full items-center justify-center px-2.5 text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus:outline-none focus:ring-1 focus:ring-slate-400 focus:ring-offset-0 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-200 dark:focus:ring-slate-500 last:hover:rounded-r-md last:dark:hover:rounded-r-md",
disabled && "text-slate-300 dark:text-slate-600",
action.id === "copy-value" && isCopied && action.id === "copy-value" && isCopied &&
"text-green-600 dark:text-green-500", "text-green-600 dark:text-green-500",
)} )}

147
src/core/stores/deviceStore.ts

@ -1,6 +1,6 @@
import { create, toBinary } from "@bufbuild/protobuf"; import { create, toBinary } from "@bufbuild/protobuf";
import { MeshDevice, Protobuf, Types } from "@meshtastic/core"; import { MeshDevice, Protobuf, Types } from "@meshtastic/core";
import { produce } from "immer"; import { current, produce } from "immer";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { create as createStore } from "zustand"; import { create as createStore } from "zustand";
@ -18,6 +18,14 @@ type NodeError = {
node: number; node: number;
error: string; error: string;
}; };
export type ValidConfigType = Exclude<
Protobuf.Config.Config["payloadVariant"]["case"],
"deviceUi" | "sessionkey" | undefined
>;
export type ValidModuleConfigType = Exclude<
Protobuf.ModuleConfig.ModuleConfig["payloadVariant"]["case"],
undefined
>;
export interface Device { export interface Device {
id: number; id: number;
@ -61,6 +69,28 @@ export interface Device {
setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
setWorkingConfig: (config: Protobuf.Config.Config) => void; setWorkingConfig: (config: Protobuf.Config.Config) => void;
setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void; setWorkingModuleConfig: (config: Protobuf.ModuleConfig.ModuleConfig) => void;
getWorkingConfig: (
payloadVariant: ValidConfigType,
) =>
| Protobuf.LocalOnly.LocalConfig[Exclude<ValidConfigType, undefined>]
| undefined;
getWorkingModuleConfig: (
payloadVariant: ValidModuleConfigType,
) =>
| Protobuf.LocalOnly.LocalModuleConfig[
Exclude<ValidModuleConfigType, undefined>
]
| undefined;
removeWorkingConfig: (payloadVariant?: ValidConfigType) => void;
removeWorkingModuleConfig: (payloadVariant?: ValidModuleConfigType) => void;
getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined;
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined;
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void; setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => void;
setActiveNode: (node: number) => void; setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void; setPendingSettingsChanges: (state: boolean) => void;
@ -277,7 +307,16 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
const index = device.workingConfig.findIndex( const index = device.workingConfig.findIndex(
(wc) => wc.payloadVariant.case === config.payloadVariant.case, (wc) => wc.payloadVariant.case === config.payloadVariant.case,
); );
if (index !== -1) {
if (
// This doesn't quite work as expected, but it is a workaround for now.
JSON.stringify(
current(device.config[config.payloadVariant.case]),
) ===
JSON.stringify(config.payloadVariant.value)
) {
device.workingConfig.splice(index, 1);
} else if (index !== -1) {
device.workingConfig[index] = config; device.workingConfig[index] = config;
} else { } else {
device.workingConfig.push(config); device.workingConfig.push(config);
@ -297,7 +336,16 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
wmc.payloadVariant.case === wmc.payloadVariant.case ===
moduleConfig.payloadVariant.case, moduleConfig.payloadVariant.case,
); );
if (index !== -1) { if (
JSON.stringify(
current(
device.moduleConfig[moduleConfig.payloadVariant.case],
),
) ===
JSON.stringify(moduleConfig.payloadVariant.value)
) {
device.workingModuleConfig.splice(index, 1);
} else if (index !== -1) {
device.workingModuleConfig[index] = moduleConfig; device.workingModuleConfig[index] = moduleConfig;
} else { } else {
device.workingModuleConfig.push(moduleConfig); device.workingModuleConfig.push(moduleConfig);
@ -305,6 +353,99 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}), }),
); );
}, },
getWorkingConfig: (payloadVariant: ValidConfigType) => {
const device = get().devices.get(id);
if (!device) return;
return device.workingConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value;
},
getWorkingModuleConfig: (payloadVariant: ValidModuleConfigType) => {
const device = get().devices.get(id);
if (!device) return;
return device.workingModuleConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value;
},
removeWorkingConfig: (payloadVariant?: ValidConfigType) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
if (!payloadVariant) {
device.workingConfig = [];
return;
}
const index = device.workingConfig.findIndex(
(wc: Protobuf.Config.Config) =>
wc.payloadVariant.case === payloadVariant,
);
if (index !== -1) {
device.workingConfig.splice(index, 1);
}
}),
);
},
removeWorkingModuleConfig: (
payloadVariant?: ValidModuleConfigType,
) => {
set(
produce<DeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) return;
if (!payloadVariant) {
device.workingModuleConfig = [];
return;
}
const index = device.workingModuleConfig.findIndex(
(wc: Protobuf.ModuleConfig.ModuleConfig) =>
wc.payloadVariant.case === payloadVariant,
);
if (index !== -1) {
device.workingModuleConfig.splice(index, 1);
}
}),
);
},
getEffectiveConfig<K extends ValidConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalConfig[K] | undefined {
if (!payloadVariant) return;
const device = get().devices.get(id);
if (!device) return;
return {
...device.config[payloadVariant],
...(device.workingConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value),
};
},
getEffectiveModuleConfig<K extends ValidModuleConfigType>(
payloadVariant: K,
): Protobuf.LocalOnly.LocalModuleConfig[K] | undefined {
const device = get().devices.get(id);
if (!device) return;
return {
...device.moduleConfig[payloadVariant],
...(device.workingModuleConfig.find(
(c) => c.payloadVariant.case === payloadVariant,
)?.payloadVariant.value),
};
},
setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => { setHardware: (hardware: Protobuf.Mesh.MyNodeInfo) => {
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {

1
src/i18n/locales/en/common.json

@ -71,6 +71,7 @@
"unset": "UNSET", "unset": "UNSET",
"fallbackName": "Meshtastic {{last4}}", "fallbackName": "Meshtastic {{last4}}",
"formValidation": { "formValidation": {
"unsavedChanges": "There are unsaved changes",
"tooBig": { "tooBig": {
"string": "Too long, expected less than or equal to {{maximum}} characters.", "string": "Too long, expected less than or equal to {{maximum}} characters.",
"number": "Too big, expected a number smaller than or equal to {{maximum}}.", "number": "Too big, expected a number smaller than or equal to {{maximum}}.",

48
src/pages/Config/DeviceConfig.tsx

@ -13,44 +13,74 @@ import {
TabsTrigger, TabsTrigger,
} from "@components/UI/Tabs.tsx"; } from "@components/UI/Tabs.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDevice, type ValidConfigType } from "@core/stores/deviceStore.ts";
import { useMemo } from "react";
import type { ComponentType } from "react";
import type { UseFormReturn } from "react-hook-form";
export const DeviceConfig = () => { interface ConfigProps {
// We can get rid of this exception if we import every config schema and pass the union type
// deno-lint-ignore no-explicit-any
onFormInit: (methods: UseFormReturn<any>) => void;
}
type TabItem = {
case: ValidConfigType;
label: string;
element: ComponentType<ConfigProps>;
count?: number;
};
export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
const { getWorkingConfig } = useDevice();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const tabs = [ const tabs: TabItem[] = [
{ {
case: "device",
label: t("page.tabDevice"), label: t("page.tabDevice"),
element: Device, element: Device,
count: 0, count: 0,
}, },
{ {
case: "position",
label: t("page.tabPosition"), label: t("page.tabPosition"),
element: Position, element: Position,
}, },
{ {
case: "power",
label: t("page.tabPower"), label: t("page.tabPower"),
element: Power, element: Power,
}, },
{ {
case: "network",
label: t("page.tabNetwork"), label: t("page.tabNetwork"),
element: Network, element: Network,
}, },
{ {
case: "display",
label: t("page.tabDisplay"), label: t("page.tabDisplay"),
element: Display, element: Display,
}, },
{ {
case: "lora",
label: t("page.tabLora"), label: t("page.tabLora"),
element: LoRa, element: LoRa,
}, },
{ {
case: "bluetooth",
label: t("page.tabBluetooth"), label: t("page.tabBluetooth"),
element: Bluetooth, element: Bluetooth,
}, },
{ {
case: "security",
label: t("page.tabSecurity"), label: t("page.tabSecurity"),
element: Security, element: Security,
}, },
]; ] as const;
const flags = useMemo(
() => new Map(tabs.map((tab) => [tab.case, getWorkingConfig(tab.case)])),
[tabs, getWorkingConfig],
);
return ( return (
<Tabs defaultValue={t("page.tabDevice")}> <Tabs defaultValue={t("page.tabDevice")}>
@ -59,15 +89,23 @@ export const DeviceConfig = () => {
<TabsTrigger <TabsTrigger
key={tab.label} key={tab.label}
value={tab.label} value={tab.label}
className="dark:text-white" className="dark:text-white relative"
> >
{tab.label} {tab.label}
{flags.get(tab.case) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25">
</span>
<span className="relative inline-flex size-3 rounded-full bg-sky-500">
</span>
</span>
)}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}> <TabsContent key={tab.label} value={tab.label}>
<tab.element /> <tab.element onFormInit={onFormInit} />
</TabsContent> </TabsContent>
))} ))}
</Tabs> </Tabs>

121
src/pages/Config/index.tsx

@ -1,27 +1,49 @@
import { useAppStore } from "../../core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "../../components/UI/Sidebar/SidebarButton.tsx"; import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx"; import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react"; import {
BoxesIcon,
RefreshCwIcon,
SaveIcon,
SaveOff,
SettingsIcon,
} from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@core/utils/cn.ts";
import type { UseFormReturn } from "react-hook-form";
const ConfigPage = () => { const ConfigPage = () => {
const { workingConfig, workingModuleConfig, connection } = useDevice(); const {
workingConfig,
workingModuleConfig,
connection,
removeWorkingConfig,
removeWorkingModuleConfig,
} = useDevice();
const { hasErrors } = useAppStore(); const { hasErrors } = useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState< const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module" "device" | "module"
>("device"); >("device");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const isError = hasErrors(); const isError = hasErrors();
const { t } = useTranslation("deviceConfig"); const { t } = useTranslation("deviceConfig");
const onFormInit = (methods: UseFormReturn) => {
setFormMethods(methods);
};
const handleSave = async () => { const handleSave = async () => {
if (hasErrors()) { if (hasErrors()) {
return toast({ return toast({
@ -29,6 +51,10 @@ const ConfigPage = () => {
description: t("toast.validationError.description"), description: t("toast.validationError.description"),
}); });
} }
if (
(activeConfigSection === "device" && workingConfig.length === 0) ||
(activeConfigSection === "module" && workingModuleConfig.length === 0)
) return;
setIsSaving(true); setIsSaving(true);
try { try {
@ -60,7 +86,20 @@ const ConfigPage = () => {
); );
setIsSaving(false); setIsSaving(false);
} }
await connection?.commitEditSettings(); await connection?.commitEditSettings().then(() => {
if (activeConfigSection === "device") {
removeWorkingConfig();
} else {
removeWorkingModuleConfig();
}
if (formMethods) {
formMethods.reset({
keepDefaultValues: true,
keepDirty: false,
keepDirtyValues: true,
});
}
});
} catch (_error) { } catch (_error) {
toast({ toast({
title: t("toast.configSaveError.title"), title: t("toast.configSaveError.title"),
@ -71,6 +110,18 @@ const ConfigPage = () => {
} }
}; };
const handleReset = () => {
if (formMethods) {
formMethods.reset();
}
if (activeConfigSection === "device") {
removeWorkingConfig();
} else {
removeWorkingModuleConfig();
}
};
const leftSidebar = useMemo( const leftSidebar = useMemo(
() => ( () => (
<Sidebar> <Sidebar>
@ -96,6 +147,51 @@ const ConfigPage = () => {
[activeConfigSection], [activeConfigSection],
); );
const buttonOpacity = useMemo(
() => (formMethods?.formState.isDirty ||
(activeConfigSection === "device" && workingConfig.length > 0) ||
(activeConfigSection === "module" && workingModuleConfig.length > 0)
? "opacity-100"
: "opacity-0"),
[formMethods?.formState.isDirty, workingConfig, workingModuleConfig],
);
const actions = useMemo(() => [
{
key: "unsavedChanges",
label: t("common:formValidation.unsavedChanges"),
onClick: () => {},
className: cn([
"bg-blue-500 hover:bg-blue-500 text-white hover:text-white",
buttonOpacity,
"transition-opacity",
]),
},
{
key: "reset",
icon: RefreshCwIcon,
label: t("common:button.reset"),
onClick: handleReset,
className: cn([buttonOpacity, "transition-opacity"]),
},
{
key: "save",
icon: isError ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving ||
(activeConfigSection === "device" && workingConfig.length === 0) ||
(activeConfigSection === "module" && workingModuleConfig.length === 0),
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
onClick: handleSave,
label: t("common:button.save"),
},
], [
activeConfigSection,
isError,
isSaving,
buttonOpacity,
]);
return ( return (
<> <>
<PageLayout <PageLayout
@ -104,18 +200,11 @@ const ConfigPage = () => {
label={activeConfigSection === "device" label={activeConfigSection === "device"
? t("navigation.radioConfig") ? t("navigation.radioConfig")
: t("navigation.moduleConfig")} : t("navigation.moduleConfig")}
actions={[ actions={actions}
{
key: "save",
icon: isError ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: isSaving,
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
onClick: handleSave,
},
]}
> >
{activeConfigSection === "device" ? <DeviceConfig /> : <ModuleConfig />} {activeConfigSection === "device"
? <DeviceConfig onFormInit={onFormInit} />
: <ModuleConfig />}
</PageLayout> </PageLayout>
</> </>
); );

2
src/validation/config/network.ts

@ -19,7 +19,7 @@ export const NetworkValidationSchema = z.object({
wifiEnabled: z.boolean(), wifiEnabled: z.boolean(),
wifiSsid: z.string().max(33), wifiSsid: z.string().max(33),
wifiPsk: z.string().max(64), wifiPsk: z.string().max(64),
ntpServer: z.string().min(2).max(33), ntpServer: z.string().min(0).max(33),
ethEnabled: z.boolean(), ethEnabled: z.boolean(),
addressMode: AddressModeEnum, addressMode: AddressModeEnum,
ipv4Config: NetworkValidationIpV4ConfigSchema, ipv4Config: NetworkValidationIpV4ConfigSchema,

2
src/validation/config/security.ts

@ -7,7 +7,7 @@ const {
isValidKey, isValidKey,
} = makePskHelpers([32]); // 256-bit } = makePskHelpers([32]); // 256-bit
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged"; const isManagedRequiredMsg = "formValidation.required.managed";
function makeSecuritySchema<KeyT>( function makeSecuritySchema<KeyT>(
keyMaker: (optional: boolean) => ZodType<KeyT>, keyMaker: (optional: boolean) => ZodType<KeyT>,

Loading…
Cancel
Save