Merge remote-tracking branch 'meshtastic-remote/master' into fix/static-ip-display
This commit is contained in:
@@ -200,10 +200,17 @@ export const CommandPalette = (): JSX.Element => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Factory Reset",
|
||||
label: "Factory Reset Device",
|
||||
icon: FactoryIcon,
|
||||
action() {
|
||||
connection?.factoryReset();
|
||||
connection?.factoryResetDevice();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Factory Reset Config",
|
||||
icon: FactoryIcon,
|
||||
action() {
|
||||
connection?.factoryResetConfig();
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Button } from "@components/UI/Button.js";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@components/UI/Dialog.js";
|
||||
|
||||
export interface PkiRegenerateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const PkiRegenerateDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: PkiRegenerateDialogProps): JSX.Element => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate Key pair?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to regenerate key pair?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => onSubmit()}>
|
||||
Regenerate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,7 @@ interface DisabledBy<T> {
|
||||
|
||||
export interface BaseFormBuilderProps<T> {
|
||||
name: Path<T>;
|
||||
disabled?: boolean;
|
||||
disabledBy?: DisabledBy<T>[];
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -62,11 +63,16 @@ export function DynamicForm<T extends FieldValues>({
|
||||
defaultValues: defaultValues,
|
||||
});
|
||||
|
||||
const isDisabled = (disabledBy?: DisabledBy<T>[]): boolean => {
|
||||
const isDisabled = (
|
||||
disabledBy?: DisabledBy<T>[],
|
||||
disabled?: boolean,
|
||||
): boolean => {
|
||||
if (disabled) return true;
|
||||
if (!disabledBy) return false;
|
||||
|
||||
return disabledBy.some((field) => {
|
||||
const value = getValues(field.fieldName);
|
||||
if (value === "always") return true;
|
||||
if (typeof value === "boolean") return field.invert ? value : !value;
|
||||
if (typeof value === "number")
|
||||
return field.invert
|
||||
@@ -109,7 +115,7 @@ export function DynamicForm<T extends FieldValues>({
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
control={control}
|
||||
disabled={isDisabled(field.disabledBy)}
|
||||
disabled={isDisabled(field.disabledBy, field.disabled)}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
))}
|
||||
|
||||
@@ -4,11 +4,14 @@ import type {
|
||||
} from "@components/Form/DynamicForm.js";
|
||||
import { Input } from "@components/UI/Input.js";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ChangeEventHandler } from "react";
|
||||
import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "text" | "number" | "password";
|
||||
inputChange?: ChangeEventHandler;
|
||||
properties?: {
|
||||
value?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
step?: number;
|
||||
@@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
|
||||
type={field.type}
|
||||
step={field.properties?.step}
|
||||
value={field.type === "number" ? Number.parseFloat(value) : value}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
if (field.inputChange) field.inputChange(e);
|
||||
onChange(
|
||||
field.type === "number"
|
||||
? Number.parseFloat(e.target.value)
|
||||
: e.target.value,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Controller, type FieldValues } from "react-hook-form";
|
||||
|
||||
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||
type: "passwordGenerator";
|
||||
hide?: boolean;
|
||||
bits?: { text: string; value: string; key: string }[];
|
||||
devicePSKBitCount: number;
|
||||
inputChange: ChangeEventHandler;
|
||||
selectChange: (event: string) => void;
|
||||
@@ -17,6 +19,7 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
|
||||
export function PasswordGenerator<T extends FieldValues>({
|
||||
control,
|
||||
field,
|
||||
disabled,
|
||||
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) {
|
||||
return (
|
||||
<Controller
|
||||
@@ -24,7 +27,9 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
control={control}
|
||||
render={({ field: { value, ...rest } }) => (
|
||||
<Generator
|
||||
hide={field.hide}
|
||||
devicePSKBitCount={field.devicePSKBitCount}
|
||||
bits={field.bits}
|
||||
inputChange={field.inputChange}
|
||||
selectChange={field.selectChange}
|
||||
buttonClick={field.buttonClick}
|
||||
@@ -33,6 +38,7 @@ export function PasswordGenerator<T extends FieldValues>({
|
||||
buttonText="Generate"
|
||||
{...field.properties}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -36,19 +36,6 @@ export const Device = (): JSX.Element => {
|
||||
formatEnumName: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "serialEnabled",
|
||||
label: "Serial Output Enabled",
|
||||
description: "Enable the device's serial console",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "debugLogEnabled",
|
||||
label: "Enabled Debug Log",
|
||||
description:
|
||||
"Output debugging information to the device's serial port (auto disables when serial client is connected)",
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
name: "buttonGpio",
|
||||
@@ -86,12 +73,6 @@ export const Device = (): JSX.Element => {
|
||||
label: "Double Tap as Button Press",
|
||||
description: "Treat double tap as button press",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isManaged",
|
||||
label: "Managed",
|
||||
description: "Is this device managed by a mesh administator",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "disableTripleClick",
|
||||
|
||||
@@ -56,6 +56,13 @@ export const LoRa = (): JSX.Element => {
|
||||
label: "Ignore MQTT",
|
||||
description: "Don't forward MQTT messages over the mesh",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "configOkToMqtt",
|
||||
label: "OK to MQTT",
|
||||
description:
|
||||
"When set to true, this configuration indicates that the user approves the packet to be uploaded to MQTT. If set to false, remote nodes are requested not to forward packets to MQTT",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
|
||||
import { DynamicForm } from "@app/components/Form/DynamicForm.js";
|
||||
import {
|
||||
getX25519PrivateKey,
|
||||
getX25519PublicKey,
|
||||
} from "@app/core/utils/x25519";
|
||||
import type { SecurityValidation } from "@app/validation/config/security.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Security = (): JSX.Element => {
|
||||
const { config, nodes, hardware, setWorkingConfig } = useDevice();
|
||||
|
||||
const [privateKey, setPrivateKey] = useState<string>(
|
||||
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
||||
);
|
||||
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
|
||||
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
|
||||
config.security?.privateKey.length ?? 32,
|
||||
);
|
||||
const [privateKeyValidationText, setPrivateKeyValidationText] =
|
||||
useState<string>();
|
||||
const [publicKey, setPublicKey] = useState<string>(
|
||||
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
||||
);
|
||||
const [adminKey, setAdminKey] = useState<string>(
|
||||
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)),
|
||||
);
|
||||
const [adminKeyValidationText, setAdminKeyValidationText] =
|
||||
useState<string>();
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const onSubmit = (data: SecurityValidation) => {
|
||||
if (privateKeyValidationText || adminKeyValidationText) return;
|
||||
|
||||
setWorkingConfig(
|
||||
new Protobuf.Config.Config({
|
||||
payloadVariant: {
|
||||
case: "security",
|
||||
value: {
|
||||
...data,
|
||||
adminKey: [toByteArray(adminKey)],
|
||||
privateKey: toByteArray(privateKey),
|
||||
publicKey: toByteArray(publicKey),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const validateKey = (
|
||||
input: string,
|
||||
count: number,
|
||||
setValidationText: (
|
||||
value: React.SetStateAction<string | undefined>,
|
||||
) => void,
|
||||
) => {
|
||||
try {
|
||||
if (input.length % 4 !== 0 || toByteArray(input).length !== count) {
|
||||
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
||||
} else {
|
||||
setValidationText(undefined);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
|
||||
}
|
||||
};
|
||||
|
||||
const privateKeyClickEvent = () => {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const pkiRegenerate = () => {
|
||||
const privateKey = getX25519PrivateKey();
|
||||
const publicKey = getX25519PublicKey(privateKey);
|
||||
|
||||
setPrivateKey(fromByteArray(privateKey));
|
||||
setPublicKey(fromByteArray(publicKey));
|
||||
validateKey(
|
||||
fromByteArray(privateKey),
|
||||
privateKeyBitCount,
|
||||
setPrivateKeyValidationText,
|
||||
);
|
||||
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const privateKeyInputChangeEvent = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const privateKeyB64String = e.target.value;
|
||||
setPrivateKey(privateKeyB64String);
|
||||
validateKey(
|
||||
privateKeyB64String,
|
||||
privateKeyBitCount,
|
||||
setPrivateKeyValidationText,
|
||||
);
|
||||
|
||||
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
|
||||
setPublicKey(fromByteArray(publicKey));
|
||||
};
|
||||
|
||||
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const psk = e.currentTarget?.value;
|
||||
setAdminKey(psk);
|
||||
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
|
||||
};
|
||||
|
||||
const privateKeySelectChangeEvent = (e: string) => {
|
||||
const count = Number.parseInt(e);
|
||||
setPrivateKeyBitCount(count);
|
||||
validateKey(privateKey, count, setPrivateKeyValidationText);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicForm<SecurityValidation>
|
||||
onSubmit={onSubmit}
|
||||
submitType="onChange"
|
||||
defaultValues={{
|
||||
...config.security,
|
||||
...{
|
||||
adminKey: adminKey,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
||||
isManaged: config.security?.isManaged ?? false,
|
||||
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
||||
serialEnabled: config.security?.serialEnabled ?? false,
|
||||
},
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: "Security Settings",
|
||||
description: "Settings for the Security configuration",
|
||||
fields: [
|
||||
{
|
||||
type: "passwordGenerator",
|
||||
name: "privateKey",
|
||||
label: "Private Key",
|
||||
description: "Used to create a shared key with a remote device",
|
||||
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
||||
validationText: privateKeyValidationText,
|
||||
devicePSKBitCount: privateKeyBitCount,
|
||||
inputChange: privateKeyInputChangeEvent,
|
||||
selectChange: privateKeySelectChangeEvent,
|
||||
hide: !privateKeyVisible,
|
||||
buttonClick: privateKeyClickEvent,
|
||||
properties: {
|
||||
value: privateKey,
|
||||
action: {
|
||||
icon: privateKeyVisible ? EyeOff : Eye,
|
||||
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "publicKey",
|
||||
label: "Public Key",
|
||||
disabled: true,
|
||||
description:
|
||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
||||
properties: {
|
||||
value: publicKey,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Admin Settings",
|
||||
description: "Settings for Admin",
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "adminChannelEnabled",
|
||||
label: "Allow Legacy Admin",
|
||||
description:
|
||||
"Allow incoming device control over the insecure legacy admin channel",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isManaged",
|
||||
label: "Managed",
|
||||
description:
|
||||
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "adminKey",
|
||||
label: "Admin Key",
|
||||
description:
|
||||
"The public key authorized to send admin messages to this node",
|
||||
validationText: adminKeyValidationText,
|
||||
inputChange: adminKeyInputChangeEvent,
|
||||
disabledBy: [
|
||||
{ fieldName: "adminChannelEnabled", invert: true },
|
||||
],
|
||||
properties: {
|
||||
value: adminKey,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Logging Settings",
|
||||
description: "Settings for Logging",
|
||||
fields: [
|
||||
{
|
||||
type: "toggle",
|
||||
name: "debugLogApiEnabled",
|
||||
label: "Enable Debug Log API",
|
||||
description:
|
||||
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "serialEnabled",
|
||||
label: "Serial Output Enabled",
|
||||
description: "Serial Console over the Stream API",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PkiRegenerateDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={() => setDialogOpen(false)}
|
||||
onSubmit={() => pkiRegenerate()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export interface PageLayoutProps {
|
||||
children: React.ReactNode;
|
||||
actions?: {
|
||||
icon: LucideIcon;
|
||||
iconClasses?: string;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
}
|
||||
@@ -39,7 +40,7 @@ export const PageLayout = ({
|
||||
className="transition-all hover:text-accent"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<action.icon />
|
||||
<action.icon className={action.iconClasses} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const Sidebar = ({ children }: SidebarProps): JSX.Element => {
|
||||
<div className="flex items-center">
|
||||
<ZapIcon size={24} viewBox={"0 0 36 24"} />
|
||||
<Subtle>
|
||||
{myNode?.deviceMetrics?.voltage.toPrecision(3) ?? "UNK"} volts
|
||||
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
|
||||
</Subtle>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -9,63 +9,95 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/UI/Select.js";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
|
||||
hide?: boolean;
|
||||
devicePSKBitCount?: number;
|
||||
value: string;
|
||||
variant: "default" | "invalid";
|
||||
buttonText?: string;
|
||||
bits?: { text: string; value: string; key: string }[];
|
||||
selectChange: (event: string) => void;
|
||||
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
action?: {
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
};
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
|
||||
(
|
||||
{
|
||||
hide = true,
|
||||
devicePSKBitCount,
|
||||
variant,
|
||||
value,
|
||||
buttonText,
|
||||
bits = [
|
||||
{ text: "256 bit", value: "32", key: "bit256" },
|
||||
{ text: "128 bit", value: "16", key: "bit128" },
|
||||
{ text: "8 bit", value: "1", key: "bit8" },
|
||||
],
|
||||
selectChange,
|
||||
inputChange,
|
||||
buttonClick,
|
||||
action,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Invokes onChange event on the input element when the value changes from the parent component
|
||||
React.useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
const setValue = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
|
||||
if (!setValue) return;
|
||||
inputRef.current.value = "";
|
||||
setValue.call(inputRef.current, value);
|
||||
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, [value]);
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
type={hide ? "password" : "text"}
|
||||
id="pskInput"
|
||||
variant={variant}
|
||||
value={value}
|
||||
onChange={inputChange}
|
||||
action={action}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Select
|
||||
value={devicePSKBitCount?.toString()}
|
||||
onValueChange={(e) => selectChange(e)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="!max-w-max">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="bit256" value="32">
|
||||
256 bit
|
||||
</SelectItem>
|
||||
<SelectItem key="bit128" value="16">
|
||||
128 bit
|
||||
</SelectItem>
|
||||
<SelectItem key="bit8" value="1">
|
||||
8 bit
|
||||
</SelectItem>
|
||||
{bits.map(({ text, value, key }) => (
|
||||
<SelectItem key={key} value={value}>
|
||||
{text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="success"
|
||||
onClick={buttonClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{buttonText}
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface InputProps
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, variant, prefix, suffix, action, ...props }, ref) => {
|
||||
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{prefix && (
|
||||
@@ -45,6 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className,
|
||||
inputVariants({ variant }),
|
||||
)}
|
||||
value={value}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -191,6 +191,9 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
|
||||
device.config.bluetooth = config.payloadVariant.value;
|
||||
break;
|
||||
}
|
||||
case "security": {
|
||||
device.config.security = config.payloadVariant.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
|
||||
export function getX25519PrivateKey(): Uint8Array {
|
||||
const key = x25519.utils.randomPrivateKey();
|
||||
|
||||
key[0] &= 248;
|
||||
key[31] &= 127;
|
||||
key[31] |= 64;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function getX25519PublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return x25519.getPublicKey(privateKey);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LoRa } from "@components/PageComponents/Config/LoRa.js";
|
||||
import { Network } from "@components/PageComponents/Config/Network.js";
|
||||
import { Position } from "@components/PageComponents/Config/Position.js";
|
||||
import { Power } from "@components/PageComponents/Config/Power.js";
|
||||
import { Security } from "@components/PageComponents/Config/Security.js";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -47,6 +48,10 @@ export const DeviceConfig = (): JSX.Element => {
|
||||
label: "Bluetooth",
|
||||
element: Bluetooth,
|
||||
},
|
||||
{
|
||||
label: "Security",
|
||||
element: Security,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -84,7 +84,7 @@ export const Dashboard = () => {
|
||||
<div className="m-auto flex flex-col gap-3 text-center">
|
||||
<ListPlusIcon size={48} className="mx-auto text-textSecondary" />
|
||||
<H3>No Devices</H3>
|
||||
<Subtle>Connect atleast one device to get started</Subtle>
|
||||
<Subtle>Connect at least one device to get started</Subtle>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => setConnectDialogOpen(true)}
|
||||
|
||||
+94
-96
@@ -87,110 +87,108 @@ export const MapPage = (): JSX.Element => {
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<PageLayout
|
||||
label="Map"
|
||||
noPadding={true}
|
||||
actions={[
|
||||
{
|
||||
icon: ZoomInIcon,
|
||||
onClick() {
|
||||
map?.zoomIn();
|
||||
},
|
||||
<PageLayout
|
||||
label="Map"
|
||||
noPadding={true}
|
||||
actions={[
|
||||
{
|
||||
icon: ZoomInIcon,
|
||||
onClick() {
|
||||
map?.zoomIn();
|
||||
},
|
||||
{
|
||||
icon: ZoomOutIcon,
|
||||
onClick() {
|
||||
map?.zoomOut();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ZoomOutIcon,
|
||||
onClick() {
|
||||
map?.zoomOut();
|
||||
},
|
||||
{
|
||||
icon: BoxSelectIcon,
|
||||
onClick() {
|
||||
getBBox();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: BoxSelectIcon,
|
||||
onClick() {
|
||||
getBBox();
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MapGl
|
||||
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
||||
// onClick={(e) => {
|
||||
// const waypoint = new Protobuf.Waypoint({
|
||||
// name: "test",
|
||||
// description: "test description",
|
||||
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
|
||||
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
|
||||
// });
|
||||
// addWaypoint(waypoint);
|
||||
// connection?.sendWaypoint(waypoint, "broadcast");
|
||||
// }}
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MapGl
|
||||
mapStyle="https://raw.githubusercontent.com/hc-oss/maplibre-gl-styles/master/styles/osm-mapnik/v8/default.json"
|
||||
// onClick={(e) => {
|
||||
// const waypoint = new Protobuf.Waypoint({
|
||||
// name: "test",
|
||||
// description: "test description",
|
||||
// latitudeI: Math.trunc(e.lngLat.lat * 1e7),
|
||||
// longitudeI: Math.trunc(e.lngLat.lng * 1e7)
|
||||
// });
|
||||
// addWaypoint(waypoint);
|
||||
// connection?.sendWaypoint(waypoint, "broadcast");
|
||||
// }}
|
||||
|
||||
// @ts-ignore
|
||||
attributionControl={false}
|
||||
renderWorldCopies={false}
|
||||
maxPitch={0}
|
||||
style={{
|
||||
filter: darkMode
|
||||
? "brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)"
|
||||
: "",
|
||||
}}
|
||||
dragRotate={false}
|
||||
touchZoomRotate={false}
|
||||
initialViewState={{
|
||||
zoom: 1.6,
|
||||
latitude: 35,
|
||||
longitude: 0,
|
||||
}}
|
||||
>
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
longitude={wp.longitudeI / 1e7}
|
||||
latitude={wp.latitudeI / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div>
|
||||
<MapPinIcon size={16} />
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* {rasterSources.map((source, index) => (
|
||||
// @ts-ignore
|
||||
attributionControl={false}
|
||||
renderWorldCopies={false}
|
||||
maxPitch={0}
|
||||
style={{
|
||||
filter: darkMode
|
||||
? "brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7)"
|
||||
: "",
|
||||
}}
|
||||
dragRotate={false}
|
||||
touchZoomRotate={false}
|
||||
initialViewState={{
|
||||
zoom: 1.6,
|
||||
latitude: 35,
|
||||
longitude: 0,
|
||||
}}
|
||||
>
|
||||
{waypoints.map((wp) => (
|
||||
<Marker
|
||||
key={wp.id}
|
||||
longitude={(wp.longitudeI ?? 0) / 1e7}
|
||||
latitude={(wp.latitudeI ?? 0) / 1e7}
|
||||
anchor="bottom"
|
||||
>
|
||||
<div>
|
||||
<MapPinIcon size={16} />
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
{/* {rasterSources.map((source, index) => (
|
||||
<Source key={index} type="raster" {...source}>
|
||||
<Layer type="raster" />
|
||||
</Source>
|
||||
))} */}
|
||||
{allNodes.map((node) => {
|
||||
if (node.position?.latitudeI) {
|
||||
return (
|
||||
<Marker
|
||||
key={node.num}
|
||||
longitude={node.position.longitudeI / 1e7}
|
||||
latitude={node.position.latitudeI / 1e7}
|
||||
style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||
anchor="bottom"
|
||||
onClick={() => {
|
||||
map?.easeTo({
|
||||
zoom: 12,
|
||||
center: [
|
||||
(node.position?.longitudeI ?? 0) / 1e7,
|
||||
(node.position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
||||
<Hashicon value={node.num.toString()} size={22} />
|
||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
||||
{node.user?.longName}
|
||||
</Subtle>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MapGl>
|
||||
</PageLayout>
|
||||
</div>
|
||||
{allNodes.map((node) => {
|
||||
if (node.position?.latitudeI) {
|
||||
return (
|
||||
<Marker
|
||||
key={node.num}
|
||||
longitude={(node.position.longitudeI ?? 0) / 1e7}
|
||||
latitude={(node.position.latitudeI ?? 0) / 1e7}
|
||||
style={{ filter: darkMode ? "invert(1)" : "" }}
|
||||
anchor="bottom"
|
||||
onClick={() => {
|
||||
map?.easeTo({
|
||||
zoom: 12,
|
||||
center: [
|
||||
(node.position?.longitudeI ?? 0) / 1e7,
|
||||
(node.position?.latitudeI ?? 0) / 1e7,
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer gap-2 rounded-md border bg-backgroundPrimary p-1.5">
|
||||
<Hashicon value={node.num.toString()} size={22} />
|
||||
<Subtle className={cn(zoom < 12 && "hidden")}>
|
||||
{node.user?.longName}
|
||||
</Subtle>
|
||||
</div>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</MapGl>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+18
-1
@@ -8,7 +8,7 @@ import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf, Types } from "@meshtastic/js";
|
||||
import { getChannelName } from "@pages/Channels.js";
|
||||
import { HashIcon, WaypointsIcon } from "lucide-react";
|
||||
import { HashIcon, LockIcon, LockOpenIcon, WaypointsIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const MessagesPage = (): JSX.Element => {
|
||||
@@ -79,6 +79,23 @@ export const MessagesPage = (): JSX.Element => {
|
||||
actions={
|
||||
chatType === "direct"
|
||||
? [
|
||||
{
|
||||
icon: nodes.get(activeChat)?.user?.publicKey.length
|
||||
? LockIcon
|
||||
: LockOpenIcon,
|
||||
iconClasses: nodes.get(activeChat)?.user?.publicKey.length
|
||||
? "text-green-600"
|
||||
: "text-yellow-300",
|
||||
async onClick() {
|
||||
const targetNode = nodes.get(activeChat)?.num;
|
||||
if (targetNode === undefined) return;
|
||||
toast({
|
||||
title: nodes.get(activeChat)?.user?.publicKey.length
|
||||
? "Chat is using PKI encryption."
|
||||
: "Chat is using PSK encryption.",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: WaypointsIcon,
|
||||
async onClick() {
|
||||
|
||||
+9
-1
@@ -8,7 +8,7 @@ import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.js";
|
||||
import { useDevice } from "@core/stores/deviceStore.js";
|
||||
import { Hashicon } from "@emeraldpay/hashicon-react";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { LockIcon, LockOpenIcon, TrashIcon } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import { base16 } from "rfc4648";
|
||||
|
||||
@@ -38,6 +38,7 @@ export const NodesPage = (): JSX.Element => {
|
||||
{ title: "MAC Address", type: "normal", sortable: true },
|
||||
{ title: "Last Heard", type: "normal", sortable: true },
|
||||
{ title: "SNR", type: "normal", sortable: true },
|
||||
{ title: "Encryption", type: "normal", sortable: false },
|
||||
{ title: "Connection", type: "normal", sortable: true },
|
||||
{ title: "Remove", type: "normal", sortable: false },
|
||||
]}
|
||||
@@ -73,6 +74,13 @@ export const NodesPage = (): JSX.Element => {
|
||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||
{(node.snr + 10) * 5}raw
|
||||
</Mono>,
|
||||
<Mono key="pki">
|
||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
||||
<LockIcon className="text-green-600" />
|
||||
) : (
|
||||
<LockOpenIcon className="text-yellow-300" />
|
||||
)}
|
||||
</Mono>,
|
||||
<Mono key="hops">
|
||||
{node.lastHeard !== 0
|
||||
? node.viaMqtt === false && node.hopsAway === 0
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Protobuf } from "@meshtastic/js";
|
||||
import { IsBoolean, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class BluetoothValidation
|
||||
implements Omit<Protobuf.Config.Config_BluetoothConfig, keyof Message>
|
||||
implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_BluetoothConfig,
|
||||
keyof Message | "deviceLoggingEnabled"
|
||||
>
|
||||
{
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Protobuf } from "@meshtastic/js";
|
||||
import { IsArray, IsBoolean, IsEnum, IsInt, Max, Min } from "class-validator";
|
||||
|
||||
export class LoRaValidation
|
||||
implements Omit<Protobuf.Config.Config_LoRaConfig, keyof Message>
|
||||
implements
|
||||
Omit<Protobuf.Config.Config_LoRaConfig, keyof Message | "paFanDisabled">
|
||||
{
|
||||
@IsBoolean()
|
||||
usePreset: boolean;
|
||||
@@ -59,4 +60,7 @@ export class LoRaValidation
|
||||
|
||||
@IsBoolean()
|
||||
ignoreMqtt: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
configOkToMqtt: boolean;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Protobuf } from "@meshtastic/js";
|
||||
import { IsBoolean, IsInt, IsNumber, Max, Min } from "class-validator";
|
||||
|
||||
export class PowerValidation
|
||||
implements Omit<Protobuf.Config.Config_PowerConfig, keyof Message>
|
||||
implements
|
||||
Omit<Protobuf.Config.Config_PowerConfig, keyof Message | "powermonEnables">
|
||||
{
|
||||
@IsBoolean()
|
||||
isPowerSaving: boolean;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
import type { Protobuf } from "@meshtastic/js";
|
||||
import { IsBoolean, IsString } from "class-validator";
|
||||
|
||||
export class SecurityValidation
|
||||
implements
|
||||
Omit<
|
||||
Protobuf.Config.Config_SecurityConfig,
|
||||
keyof Message | "adminKey" | "privateKey" | "publicKey"
|
||||
>
|
||||
{
|
||||
@IsBoolean()
|
||||
adminChannelEnabled: boolean;
|
||||
|
||||
@IsString()
|
||||
adminKey: string;
|
||||
|
||||
@IsBoolean()
|
||||
bluetoothLoggingEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
debugLogApiEnabled: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isManaged: boolean;
|
||||
|
||||
@IsString()
|
||||
privateKey: string;
|
||||
|
||||
@IsString()
|
||||
publicKey: string;
|
||||
|
||||
@IsBoolean()
|
||||
serialEnabled: boolean;
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { IsBoolean, IsInt } from "class-validator";
|
||||
|
||||
export class StoreForwardValidation
|
||||
implements
|
||||
Omit<Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig, keyof Message>
|
||||
Omit<
|
||||
Protobuf.ModuleConfig.ModuleConfig_StoreForwardConfig,
|
||||
keyof Message | "isServer"
|
||||
>
|
||||
{
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
Reference in New Issue
Block a user