mirror of
https://github.com/meshtastic/web.git
synced 2026-05-24 22:25:13 -04:00
Merge pull request #439 from danditomaso/fix/validate-ble-pin
fix: Add 6-digit BLE PIN validation and error management
This commit is contained in:
@@ -13,6 +13,7 @@ import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "select";
|
||||
selectChange?: (e: string) => void;
|
||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||
enumValue: {
|
||||
[s: string]: string | number;
|
||||
@@ -40,7 +41,10 @@ export function SelectInput<T extends FieldValues>({
|
||||
: [];
|
||||
return (
|
||||
<Select
|
||||
onValueChange={(e) => onChange(Number.parseInt(e))}
|
||||
onValueChange={(e) => {
|
||||
if (field.selectChange) field.selectChange(e);
|
||||
onChange(Number.parseInt(e));
|
||||
}}
|
||||
disabled={disabled}
|
||||
value={value?.toString()}
|
||||
{...remainingProperties}
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import type { BluetoothValidation } from "@app/validation/config/bluetooth.tsx";
|
||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Bluetooth = (): JSX.Element => {
|
||||
export const Bluetooth = () => {
|
||||
const { config, setWorkingConfig } = useDevice();
|
||||
const [bluetoothValidationText, setBluetoothValidationText] =
|
||||
useState<string>();
|
||||
const {
|
||||
hasErrors,
|
||||
getErrorMessage,
|
||||
hasFieldError,
|
||||
addError,
|
||||
removeError,
|
||||
clearErrors,
|
||||
} = useAppStore();
|
||||
|
||||
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", "Bluetooth Pin is required");
|
||||
}
|
||||
|
||||
// clear any existing errors
|
||||
clearErrors();
|
||||
|
||||
// if it starts with 0 show error
|
||||
if (pin[0] === "0") {
|
||||
return addError("fixedPin", "Bluetooth Pin cannot start with 0");
|
||||
}
|
||||
// if it's not 6 digits show error
|
||||
if (pin.length < 6) {
|
||||
return addError("fixedPin", "Pin must be 6 digits");
|
||||
}
|
||||
|
||||
removeError("fixedPin");
|
||||
};
|
||||
|
||||
const bluetoothPinChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value[0] === "0") {
|
||||
setBluetoothValidationText("Bluetooth Pin cannot start with 0.");
|
||||
} else {
|
||||
setBluetoothValidationText("");
|
||||
}
|
||||
const numericValue = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||
setBluetoothPin(numericValue);
|
||||
validateBluetoothPin(numericValue);
|
||||
};
|
||||
|
||||
const onSubmit = (data: BluetoothValidation) => {
|
||||
if (hasErrors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkingConfig(
|
||||
new Protobuf.Config.Config({
|
||||
payloadVariant: {
|
||||
@@ -48,6 +82,12 @@ export const Bluetooth = (): JSX.Element => {
|
||||
name: "mode",
|
||||
label: "Pairing mode",
|
||||
description: "Pin selection behaviour.",
|
||||
selectChange: (e) => {
|
||||
if (e !== "1") {
|
||||
setBluetoothPin("");
|
||||
removeError("fixedPin");
|
||||
}
|
||||
},
|
||||
disabledBy: [
|
||||
{
|
||||
fieldName: "enabled",
|
||||
@@ -63,7 +103,9 @@ export const Bluetooth = (): JSX.Element => {
|
||||
name: "fixedPin",
|
||||
label: "Pin",
|
||||
description: "Pin to use when pairing",
|
||||
validationText: bluetoothValidationText,
|
||||
validationText: hasFieldError("fixedPin")
|
||||
? getErrorMessage("fixedPin")
|
||||
: "",
|
||||
inputChange: bluetoothPinChangeEvent,
|
||||
disabledBy: [
|
||||
{
|
||||
@@ -77,7 +119,9 @@ export const Bluetooth = (): JSX.Element => {
|
||||
fieldName: "enabled",
|
||||
},
|
||||
],
|
||||
properties: {},
|
||||
properties: {
|
||||
value: bluetoothPin,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -70,7 +70,6 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
onCheckedChange={(checked) => {
|
||||
checked ? setHTTPS(true) : setHTTPS(false);
|
||||
}}
|
||||
|
||||
// label="Use TLS"
|
||||
// description="Description"
|
||||
disabled={
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from "@app/core/utils/cn.ts";
|
||||
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
|
||||
import Footer from "./UI/Footer";
|
||||
import { Spinner } from "./UI/Spinner";
|
||||
|
||||
export interface PageLayoutProps {
|
||||
label: string;
|
||||
@@ -10,6 +11,8 @@ export interface PageLayoutProps {
|
||||
icon: LucideIcon;
|
||||
iconClasses?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -18,7 +21,7 @@ export const PageLayout = ({
|
||||
noPadding,
|
||||
actions,
|
||||
children,
|
||||
}: PageLayoutProps): JSX.Element => {
|
||||
}: PageLayoutProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
@@ -33,14 +36,22 @@ export const PageLayout = ({
|
||||
<div className="flex w-full items-center">
|
||||
<span className="w-full text-lg font-medium">{label}</span>
|
||||
<div className="flex justify-end space-x-4">
|
||||
{actions?.map((action, index) => (
|
||||
{actions?.map((action) => (
|
||||
<button
|
||||
key={action.icon.displayName}
|
||||
type="button"
|
||||
disabled={action?.disabled}
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon className={action.iconClasses} />
|
||||
{action?.isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<action.icon
|
||||
className={action.iconClasses}
|
||||
aria-disabled={action.disabled}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
104
src/components/UI/Spinner.tsx
Normal file
104
src/components/UI/Spinner.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cn } from "@app/core/utils/cn";
|
||||
|
||||
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-8 w-8",
|
||||
lg: "h-12 w-12",
|
||||
};
|
||||
|
||||
export function Spinner({ className, size = "md", ...props }: SpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
aria-label="Loading..."
|
||||
className={cn(
|
||||
"flex items-center justify-center fade-in-50 fade-out-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
className={cn("animate-spin-slow stroke-current", sizeClasses[size])}
|
||||
role="img"
|
||||
aria-label="Loading spinner"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<line
|
||||
x1="128"
|
||||
y1="32"
|
||||
x2="128"
|
||||
y2="64"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="195.9"
|
||||
y1="60.1"
|
||||
x2="173.3"
|
||||
y2="82.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="224"
|
||||
y1="128"
|
||||
x2="192"
|
||||
y2="128"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="195.9"
|
||||
y1="195.9"
|
||||
x2="173.3"
|
||||
y2="173.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="128"
|
||||
y1="224"
|
||||
x2="128"
|
||||
y2="192"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="60.1"
|
||||
y1="195.9"
|
||||
x2="82.7"
|
||||
y2="173.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="32"
|
||||
y1="128"
|
||||
x2="64"
|
||||
y2="128"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
<line
|
||||
x1="60.1"
|
||||
y1="60.1"
|
||||
x2="82.7"
|
||||
y2="82.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="24"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export type AccentColor =
|
||||
| "purple"
|
||||
| "pink";
|
||||
|
||||
interface ErrorState {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
selectedDevice: number;
|
||||
devices: {
|
||||
@@ -33,11 +38,11 @@ interface AppState {
|
||||
nodeNumDetails: number;
|
||||
activeChat: number;
|
||||
chatType: "broadcast" | "direct";
|
||||
errors: ErrorState[];
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => void;
|
||||
addRasterSource: (source: RasterSource) => void;
|
||||
removeRasterSource: (index: number) => void;
|
||||
|
||||
setSelectedDevice: (deviceId: number) => void;
|
||||
addDevice: (device: { id: number; num: number }) => void;
|
||||
removeDevice: (deviceId: number) => void;
|
||||
@@ -49,9 +54,18 @@ interface AppState {
|
||||
setNodeNumDetails: (nodeNum: number) => void;
|
||||
setActiveChat: (chat: number) => void;
|
||||
setChatType: (type: "broadcast" | "direct") => void;
|
||||
|
||||
// Error management
|
||||
hasErrors: () => boolean;
|
||||
getErrorMessage: (field: string) => string | undefined;
|
||||
hasFieldError: (field: string) => boolean;
|
||||
addError: (field: string, message: string) => void;
|
||||
removeError: (field: string) => void;
|
||||
clearErrors: () => void;
|
||||
setNewErrors: (newErrors: ErrorState[]) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()((set) => ({
|
||||
export const useAppStore = create<AppState>()((set, get) => ({
|
||||
selectedDevice: 0,
|
||||
devices: [],
|
||||
currentPage: "messages",
|
||||
@@ -67,6 +81,7 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
nodeNumDetails: 0,
|
||||
activeChat: Types.ChannelNumber.Primary,
|
||||
chatType: "broadcast",
|
||||
errors: [],
|
||||
|
||||
setRasterSources: (sources: RasterSource[]) => {
|
||||
set(
|
||||
@@ -146,4 +161,47 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
set(() => ({
|
||||
chatType: type,
|
||||
})),
|
||||
hasErrors: () => {
|
||||
const state = get();
|
||||
return state.errors.length > 0;
|
||||
},
|
||||
getErrorMessage: (field: string) => {
|
||||
const state = get();
|
||||
return state.errors.find((err) => err.field === field)?.message;
|
||||
},
|
||||
hasFieldError: (field: string) => {
|
||||
const state = get();
|
||||
return state.errors.some((err) => err.field === field);
|
||||
},
|
||||
addError: (field: string, message: string) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = [
|
||||
...draft.errors.filter((e) => e.field !== field),
|
||||
{ field, message },
|
||||
];
|
||||
}),
|
||||
);
|
||||
},
|
||||
removeError: (field: string) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = draft.errors.filter((e) => e.field !== field);
|
||||
}),
|
||||
);
|
||||
},
|
||||
clearErrors: () => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = [];
|
||||
}),
|
||||
);
|
||||
},
|
||||
setNewErrors: (newErrors: ErrorState[]) => {
|
||||
set(
|
||||
produce<AppState>((draft) => {
|
||||
draft.errors = newErrors;
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -99,3 +99,13 @@
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
@keyframes spin-slower {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slower 2s linear infinite;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@components/UI/Tabs.tsx";
|
||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
|
||||
export const DeviceConfig = (): JSX.Element => {
|
||||
export const DeviceConfig = () => {
|
||||
const { metadata } = useDevice();
|
||||
|
||||
const tabs = [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import { useDevice } from "@app/core/stores/deviceStore.ts";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
@@ -6,15 +7,62 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
|
||||
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
|
||||
import { BoxesIcon, SaveIcon, SettingsIcon } from "lucide-react";
|
||||
import { BoxesIcon, SaveIcon, SaveOff, SettingsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const ConfigPage = (): JSX.Element => {
|
||||
const ConfigPage = () => {
|
||||
const { workingConfig, workingModuleConfig, connection } = useDevice();
|
||||
const { hasErrors } = useAppStore();
|
||||
const [activeConfigSection, setActiveConfigSection] = useState<
|
||||
"device" | "module"
|
||||
>("device");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const isError = hasErrors();
|
||||
|
||||
const handleSave = async () => {
|
||||
if (hasErrors()) {
|
||||
return toast({
|
||||
title: "Config Errors Exist",
|
||||
description: "Please fix the configuration errors before saving.",
|
||||
});
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (activeConfigSection === "device") {
|
||||
await Promise.all(
|
||||
workingConfig.map((config) =>
|
||||
connection?.setConfig(config).then(() =>
|
||||
toast({
|
||||
title: "Saving Config",
|
||||
description: `The configuration change ${config.payloadVariant.case} has been saved.`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Promise.all(
|
||||
workingModuleConfig.map((moduleConfig) =>
|
||||
connection?.setModuleConfig(moduleConfig).then(() =>
|
||||
toast({
|
||||
title: "Saving Config",
|
||||
description: `The configuration change ${moduleConfig.payloadVariant.case} has been saved.`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await connection?.commitEditSettings();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error Saving Config",
|
||||
description: "An error occurred while saving the configuration.",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -40,30 +88,11 @@ const ConfigPage = (): JSX.Element => {
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
icon: SaveIcon,
|
||||
async onClick() {
|
||||
if (activeConfigSection === "device") {
|
||||
workingConfig.map(
|
||||
async (config) =>
|
||||
await connection?.setConfig(config).then(() =>
|
||||
toast({
|
||||
title: `Config ${config.payloadVariant.case} saved`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
workingModuleConfig.map(
|
||||
async (moduleConfig) =>
|
||||
await connection?.setModuleConfig(moduleConfig).then(() =>
|
||||
toast({
|
||||
title: `Config ${moduleConfig.payloadVariant.case} saved`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await connection?.commitEditSettings();
|
||||
},
|
||||
icon: isError ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
disabled: isSaving,
|
||||
iconClasses: isError ? "text-red-400 cursor-not-allowed" : "",
|
||||
onClick: handleSave,
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user