Merge branch 'master' into feat/add-error-boundary
This commit is contained in:
+145
-89
@@ -1,5 +1,6 @@
|
||||
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
|
||||
import { DynamicForm } from "@app/components/Form/DynamicForm.tsx";
|
||||
import { useAppStore } from "@app/core/stores/appStore";
|
||||
import {
|
||||
getX25519PrivateKey,
|
||||
getX25519PublicKey,
|
||||
@@ -9,34 +10,77 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
||||
import { Protobuf } from "@meshtastic/js";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useReducer } from "react";
|
||||
import { securityReducer } from "./securityReducer";
|
||||
|
||||
export const Security = (): JSX.Element => {
|
||||
const { config, nodes, hardware, setWorkingConfig, setDialogOpen } =
|
||||
useDevice();
|
||||
export const Security = () => {
|
||||
const { config, setWorkingConfig, setDialogOpen } = useDevice();
|
||||
const {
|
||||
hasErrors,
|
||||
getErrorMessage,
|
||||
hasFieldError,
|
||||
addError,
|
||||
removeError,
|
||||
clearErrors,
|
||||
} = useAppStore();
|
||||
|
||||
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 [privateKeyDialogOpen, setPrivateKeyDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [state, dispatch] = useReducer(securityReducer, {
|
||||
privateKey: fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
|
||||
privateKeyVisible: false,
|
||||
adminKeyVisible: false,
|
||||
privateKeyBitCount: config.security?.privateKey?.length ?? 32,
|
||||
adminKeyBitCount: config.security?.adminKey?.at(0)?.length ?? 32,
|
||||
publicKey: fromByteArray(config.security?.publicKey ?? new Uint8Array(0)),
|
||||
adminKey: fromByteArray(
|
||||
config.security?.adminKey?.at(0) ?? new Uint8Array(0),
|
||||
),
|
||||
privateKeyDialogOpen: false,
|
||||
});
|
||||
|
||||
const validateKey = (
|
||||
input: string,
|
||||
count: number,
|
||||
fieldName: "privateKey" | "adminKey",
|
||||
) => {
|
||||
try {
|
||||
removeError(fieldName);
|
||||
|
||||
if (fieldName === "privateKey" && input === "") {
|
||||
addError(fieldName, "Private Key is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldName === "adminKey" && input === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.length % 4 !== 0) {
|
||||
addError(
|
||||
fieldName,
|
||||
`${fieldName === "privateKey" ? "Private" : "Admin"} Key is required to be a 256 bit pre-shared key (PSK)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const decoded = toByteArray(input);
|
||||
if (decoded.length !== count) {
|
||||
addError(fieldName, `Please enter a valid ${count * 8} bit PSK`);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
addError(
|
||||
fieldName,
|
||||
`Invalid ${fieldName === "privateKey" ? "Private" : "Admin"} Key format`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: SecurityValidation) => {
|
||||
if (privateKeyValidationText || adminKeyValidationText) return;
|
||||
if (hasErrors()) {
|
||||
return;
|
||||
}
|
||||
console.log(toByteArray(state.adminKey));
|
||||
|
||||
setWorkingConfig(
|
||||
new Protobuf.Config.Config({
|
||||
@@ -44,82 +88,56 @@ export const Security = (): JSX.Element => {
|
||||
case: "security",
|
||||
value: {
|
||||
...data,
|
||||
adminKey: [toByteArray(adminKey)],
|
||||
privateKey: toByteArray(privateKey),
|
||||
publicKey: toByteArray(publicKey),
|
||||
adminKey: [new Uint8Array(0)],
|
||||
privateKey: toByteArray(state.privateKey),
|
||||
publicKey: toByteArray(state.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 = () => {
|
||||
setPrivateKeyDialogOpen(true);
|
||||
};
|
||||
|
||||
const pkiBackupClickEvent = () => {
|
||||
setDialogOpen("pkiBackup", true);
|
||||
};
|
||||
|
||||
const pkiRegenerate = () => {
|
||||
clearErrors();
|
||||
const privateKey = getX25519PrivateKey();
|
||||
const publicKey = getX25519PublicKey(privateKey);
|
||||
|
||||
setPrivateKey(fromByteArray(privateKey));
|
||||
setPublicKey(fromByteArray(publicKey));
|
||||
dispatch({
|
||||
type: "REGENERATE_PRIV_PUB_KEY",
|
||||
payload: {
|
||||
privateKey: fromByteArray(privateKey),
|
||||
publicKey: fromByteArray(publicKey),
|
||||
},
|
||||
});
|
||||
|
||||
validateKey(
|
||||
fromByteArray(privateKey),
|
||||
privateKeyBitCount,
|
||||
setPrivateKeyValidationText,
|
||||
state.privateKeyBitCount,
|
||||
"privateKey",
|
||||
);
|
||||
|
||||
setPrivateKeyDialogOpen(false);
|
||||
};
|
||||
|
||||
const privateKeyInputChangeEvent = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const privateKeyB64String = e.target.value;
|
||||
setPrivateKey(privateKeyB64String);
|
||||
validateKey(
|
||||
privateKeyB64String,
|
||||
privateKeyBitCount,
|
||||
setPrivateKeyValidationText,
|
||||
);
|
||||
dispatch({ type: "SET_PRIVATE_KEY", payload: privateKeyB64String });
|
||||
validateKey(privateKeyB64String, state.privateKeyBitCount, "privateKey");
|
||||
|
||||
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
|
||||
setPublicKey(fromByteArray(publicKey));
|
||||
dispatch({ type: "SET_PUBLIC_KEY", payload: fromByteArray(publicKey) });
|
||||
};
|
||||
|
||||
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const psk = e.currentTarget?.value;
|
||||
setAdminKey(psk);
|
||||
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
|
||||
dispatch({ type: "SET_ADMIN_KEY", payload: psk });
|
||||
validateKey(psk, state.privateKeyBitCount, "adminKey");
|
||||
};
|
||||
|
||||
const privateKeySelectChangeEvent = (e: string) => {
|
||||
const count = Number.parseInt(e);
|
||||
setPrivateKeyBitCount(count);
|
||||
validateKey(privateKey, count, setPrivateKeyValidationText);
|
||||
dispatch({ type: "SET_PRIVATE_KEY_BIT_COUNT", payload: count });
|
||||
validateKey(state.privateKey, count, "privateKey");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -130,9 +148,9 @@ export const Security = (): JSX.Element => {
|
||||
defaultValues={{
|
||||
...config.security,
|
||||
...{
|
||||
adminKey: adminKey,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
adminKey: state.adminKey,
|
||||
privateKey: state.privateKey,
|
||||
publicKey: state.publicKey,
|
||||
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
|
||||
isManaged: config.security?.isManaged ?? false,
|
||||
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
|
||||
@@ -150,28 +168,35 @@ export const Security = (): JSX.Element => {
|
||||
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,
|
||||
validationText: hasFieldError("privateKey")
|
||||
? getErrorMessage("privateKey")
|
||||
: "",
|
||||
devicePSKBitCount: state.privateKeyBitCount,
|
||||
inputChange: privateKeyInputChangeEvent,
|
||||
selectChange: privateKeySelectChangeEvent,
|
||||
hide: !privateKeyVisible,
|
||||
hide: !state.privateKeyVisible,
|
||||
actionButtons: [
|
||||
{
|
||||
text: "Generate",
|
||||
onClick: privateKeyClickEvent,
|
||||
onClick: () =>
|
||||
dispatch({
|
||||
type: "SHOW_PRIVATE_KEY_DIALOG",
|
||||
payload: true,
|
||||
}),
|
||||
variant: "success",
|
||||
},
|
||||
{
|
||||
text: "Backup Key",
|
||||
onClick: pkiBackupClickEvent,
|
||||
onClick: () => setDialogOpen("pkiBackup", true),
|
||||
variant: "subtle",
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
value: privateKey,
|
||||
value: state.privateKey,
|
||||
action: {
|
||||
icon: privateKeyVisible ? EyeOff : Eye,
|
||||
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
|
||||
icon: state.privateKeyVisible ? EyeOff : Eye,
|
||||
onClick: () =>
|
||||
dispatch({ type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -183,7 +208,7 @@ export const Security = (): JSX.Element => {
|
||||
description:
|
||||
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
|
||||
properties: {
|
||||
value: publicKey,
|
||||
value: state.publicKey,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -207,18 +232,47 @@ export const Security = (): JSX.Element => {
|
||||
"If true, device configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
type: "passwordGenerator",
|
||||
name: "adminKey",
|
||||
label: "Admin Key",
|
||||
description:
|
||||
"The public key authorized to send admin messages to this node",
|
||||
validationText: adminKeyValidationText,
|
||||
validationText: hasFieldError("adminKey")
|
||||
? getErrorMessage("adminKey")
|
||||
: "",
|
||||
inputChange: adminKeyInputChangeEvent,
|
||||
selectChange: () => { },
|
||||
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
|
||||
devicePSKBitCount: state.privateKeyBitCount,
|
||||
hide: !state.adminKeyVisible,
|
||||
actionButtons: [
|
||||
{
|
||||
text: "Generate",
|
||||
variant: "success",
|
||||
onClick: () => {
|
||||
const adminKey = getX25519PrivateKey();
|
||||
dispatch({
|
||||
type: "REGENERATE_ADMIN_KEY",
|
||||
payload: { adminKey: fromByteArray(adminKey) },
|
||||
});
|
||||
validateKey(
|
||||
fromByteArray(adminKey),
|
||||
state.adminKeyBitCount,
|
||||
"adminKey",
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
disabledBy: [
|
||||
{ fieldName: "adminChannelEnabled", invert: true },
|
||||
],
|
||||
properties: {
|
||||
value: adminKey,
|
||||
value: state.adminKey,
|
||||
action: {
|
||||
icon: state.adminKeyVisible ? EyeOff : Eye,
|
||||
onClick: () =>
|
||||
dispatch({ type: "TOGGLE_ADMIN_KEY_VISIBILITY" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -245,9 +299,11 @@ export const Security = (): JSX.Element => {
|
||||
]}
|
||||
/>
|
||||
<PkiRegenerateDialog
|
||||
open={privateKeyDialogOpen}
|
||||
onOpenChange={() => setPrivateKeyDialogOpen(false)}
|
||||
onSubmit={() => pkiRegenerate()}
|
||||
open={state.privateKeyDialogOpen}
|
||||
onOpenChange={() =>
|
||||
dispatch({ type: "SHOW_PRIVATE_KEY_DIALOG", payload: false })
|
||||
}
|
||||
onSubmit={pkiRegenerate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { SecurityAction, SecurityState } from "./types";
|
||||
|
||||
export function securityReducer(
|
||||
state: SecurityState,
|
||||
action: SecurityAction,
|
||||
): SecurityState {
|
||||
switch (action.type) {
|
||||
case "SET_PRIVATE_KEY":
|
||||
return { ...state, privateKey: action.payload };
|
||||
case "TOGGLE_PRIVATE_KEY_VISIBILITY":
|
||||
return { ...state, privateKeyVisible: !state.privateKeyVisible };
|
||||
case "TOGGLE_ADMIN_KEY_VISIBILITY":
|
||||
return { ...state, adminKeyVisible: !state.adminKeyVisible };
|
||||
case "SET_PRIVATE_KEY_BIT_COUNT":
|
||||
return { ...state, privateKeyBitCount: action.payload };
|
||||
case "SET_PUBLIC_KEY":
|
||||
return { ...state, publicKey: action.payload };
|
||||
case "SET_ADMIN_KEY":
|
||||
return { ...state, adminKey: action.payload };
|
||||
case "SHOW_PRIVATE_KEY_DIALOG":
|
||||
return { ...state, privateKeyDialogOpen: action.payload };
|
||||
case "REGENERATE_PRIV_PUB_KEY":
|
||||
return {
|
||||
...state,
|
||||
privateKey: action.payload.privateKey,
|
||||
publicKey: action.payload.publicKey,
|
||||
privateKeyDialogOpen: false,
|
||||
};
|
||||
case "REGENERATE_ADMIN_KEY":
|
||||
return {
|
||||
...state,
|
||||
adminKey: action.payload.adminKey,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface SecurityState {
|
||||
privateKey: string;
|
||||
privateKeyVisible: boolean;
|
||||
adminKeyVisible: boolean;
|
||||
privateKeyBitCount: number;
|
||||
adminKeyBitCount: number;
|
||||
publicKey: string;
|
||||
adminKey: string;
|
||||
privateKeyDialogOpen: boolean;
|
||||
}
|
||||
|
||||
export type SecurityAction =
|
||||
| { type: "SET_PRIVATE_KEY"; payload: string }
|
||||
| { type: "TOGGLE_PRIVATE_KEY_VISIBILITY" }
|
||||
| { type: "TOGGLE_ADMIN_KEY_VISIBILITY" }
|
||||
| { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number }
|
||||
| { type: "SET_PUBLIC_KEY"; payload: string }
|
||||
| { type: "SET_ADMIN_KEY"; payload: string }
|
||||
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean }
|
||||
| {
|
||||
type: "REGENERATE_PRIV_PUB_KEY";
|
||||
payload: { privateKey: string; publicKey: string };
|
||||
}
|
||||
| { type: "REGENERATE_ADMIN_KEY"; payload: { adminKey: string } };
|
||||
@@ -57,7 +57,7 @@ export const HTTP = ({ closeDialog }: TabElementProps): JSX.Element => {
|
||||
<Input
|
||||
prefix={https ? "https://" : "http://"}
|
||||
placeholder="000.000.000.000 / meshtastic.local"
|
||||
className="text-black dark:text-black"
|
||||
className="text-slate-900 dark:text-slate-900"
|
||||
disabled={connectionInProgress}
|
||||
{...register("ip")}
|
||||
/>
|
||||
|
||||
@@ -121,7 +121,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||
|
||||
<div className="flex mt-2 text-sm">
|
||||
<div className="flex items-center grow">
|
||||
<div className="border-2 border-black rounded-sm px-0.5 mr-1">
|
||||
<div className="border-2 border-slate-900 rounded-sm px-0.5 mr-1">
|
||||
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway}
|
||||
</div>
|
||||
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div>
|
||||
|
||||
@@ -51,11 +51,11 @@ export const ChannelChat = ({
|
||||
|
||||
if (!messages?.length) {
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full container mx-auto">
|
||||
<div className="flex flex-col h-full container mx-auto">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<EmptyState />
|
||||
</div>
|
||||
<div className="shrink-0 p-4 w-full dark:bg-gray-900">
|
||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||
<MessageInput to={to} channel={channel} maxBytes={200} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ export const ChannelChat = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full container mx-auto">
|
||||
<div className="flex flex-col h-full container mx-auto">
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
|
||||
{messages.map((message, index) => {
|
||||
@@ -81,7 +81,7 @@ export const ChannelChat = ({
|
||||
<div ref={messagesEndRef} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 mt-2 p-4 w-full dark:bg-gray-900">
|
||||
<div className="shrink-0 mt-2 p-4 w-full dark:bg-slate-900">
|
||||
<MessageInput to={to} channel={channel} maxBytes={200} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
||||
const isFailed = state === MESSAGE_STATES.FAILED;
|
||||
const iconClass = cn(
|
||||
className,
|
||||
"text-gray-500 dark:text-gray-400 w-4 h-4 shrink-0",
|
||||
"text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0",
|
||||
);
|
||||
|
||||
const Icon = STATUS_ICON_MAP[state];
|
||||
@@ -99,7 +99,7 @@ const getMessageTextStyles = (state: MessageState) => {
|
||||
"break-words overflow-hidden",
|
||||
isAcknowledged
|
||||
? "text-slate-900 dark:text-white"
|
||||
: "text-slate-900 dark:text-gray-400",
|
||||
: "text-slate-900 dark:text-slate-400",
|
||||
isFailed && "text-red-500 dark:text-red-500",
|
||||
);
|
||||
};
|
||||
@@ -109,10 +109,10 @@ const TimeDisplay = ({
|
||||
className,
|
||||
}: { date: Date; className?: string }) => (
|
||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
{date.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -148,7 +148,7 @@ export const Message = ({ lastMsgSameUser, message, sender }: MessageProps) => {
|
||||
<div className="flex place-items-center gap-2 mb-1">
|
||||
<Avatar text={messageUser?.shortName} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
||||
{messageUser?.longName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user