diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index 16e60120..762bd5a7 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -5,6 +5,7 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { PkiBackupDialog } from "./PKIBackupDialog"; export const DialogManager = (): JSX.Element => { const { channels, config, dialog, setDialogOpen } = useDevice(); @@ -49,6 +50,12 @@ export const DialogManager = (): JSX.Element => { setDialogOpen("nodeRemoval", open); }} /> + { + setDialogOpen("pkiBackup", open); + }} + /> ); }; diff --git a/src/components/Dialog/PKIBackupDialog.tsx b/src/components/Dialog/PKIBackupDialog.tsx new file mode 100644 index 00000000..3737236a --- /dev/null +++ b/src/components/Dialog/PKIBackupDialog.tsx @@ -0,0 +1,104 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { Button } from "@components/UI/Button"; +import { DownloadIcon, PrinterIcon } from "lucide-react"; +import React from "react"; +import { useDevice } from "@app/core/stores/deviceStore"; +import { fromByteArray } from "base64-js"; + +export interface PkiBackupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const PkiBackupDialog = ({ + open, + onOpenChange, +}: PkiBackupDialogProps) => { + const { config } = useDevice(); + const privateKeyData = config.security?.privateKey + + // If the private data doesn't exist return null + if (!privateKeyData) { + return null + } + + const getPrivateKey = React.useMemo(() => fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), [config.security?.privateKey]); + + const renderPrintWindow = React.useCallback(() => { + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(` + + + Your Private Key + + + +

Your Private Key

+

${getPrivateKey}

+ + + `); + printWindow.document.close(); + printWindow.print(); + } + }, [getPrivateKey]); + + const createDownloadKeyFile = React.useCallback(() => { + const blob = new Blob([getPrivateKey], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "meshtastic_private_key.txt"; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [getPrivateKey]); + + + return ( + + + + Backup Key + + Its important to backup your private key and store your backup securely! + + + If you lose your private key, you will need to reset your device. + + + + + + + + + ); +}; diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index 09e97ff5..784086fb 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -7,6 +7,7 @@ import { Eye, EyeOff } from "lucide-react"; import type { ChangeEventHandler, MouseEventHandler } from "react"; import { useState } from "react"; import { Controller, type FieldValues } from "react-hook-form"; +import type { ButtonVariant } from "@components/UI/Button"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { type: "passwordGenerator"; @@ -15,7 +16,12 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps { devicePSKBitCount: number; inputChange: ChangeEventHandler; selectChange: (event: string) => void; - buttonClick: MouseEventHandler; + actionButtons: { + text: string; + onClick: React.MouseEventHandler; + variant: ButtonVariant; + className?: string; + }[]; } export function PasswordGenerator({ @@ -38,19 +44,18 @@ export function PasswordGenerator({ action={ field.hide ? { - icon: passwordShown ? EyeOff : Eye, - onClick: togglePasswordVisiblity, - } + icon: passwordShown ? EyeOff : Eye, + onClick: togglePasswordVisiblity, + } : undefined } devicePSKBitCount={field.devicePSKBitCount} bits={field.bits} inputChange={field.inputChange} selectChange={field.selectChange} - buttonClick={field.buttonClick} value={value} variant={field.validationText ? "invalid" : "default"} - buttonText="Generate" + actionButtons={field.actionButtons} {...field.properties} {...rest} disabled={disabled} diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index 3655e5f8..7b581d72 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -6,6 +6,7 @@ import { Protobuf } from "@meshtastic/js"; import { fromByteArray, toByteArray } from "base64-js"; import cryptoRandomString from "crypto-random-string"; import { useState } from "react"; +import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog"; export interface SettingsPanelProps { channel: Protobuf.Channel.Channel; @@ -22,6 +23,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { channel?.settings?.psk.length ?? 16, ); const [validationText, setValidationText] = useState(); + const [preSharedDialogOpen, setPreSharedDialogOpen] = useState(false); const onSubmit = (data: ChannelValidation) => { const channel = new Protobuf.Channel.Channel({ @@ -46,7 +48,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { }); }; - const clickEvent = () => { + const preSharedKeyRegenerate = () => { setPass( btoa( cryptoRandomString({ @@ -56,6 +58,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { ), ); setValidationText(undefined); + setPreSharedDialogOpen(false); + }; + + const preSharedClickEvent = () => { + setPreSharedDialogOpen(true); }; const validatePass = (input: string, count: number) => { @@ -79,104 +86,105 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { }; return ( - - onSubmit={onSubmit} - submitType="onSubmit" - hasSubmitButton={true} - defaultValues={{ - ...channel, - ...{ - settings: { - ...channel?.settings, - psk: pass, - positionEnabled: - channel?.settings?.moduleSettings?.positionPrecision !== + <> + + onSubmit={onSubmit} + submitType="onSubmit" + hasSubmitButton={true} + defaultValues={{ + ...channel, + ...{ + settings: { + ...channel?.settings, + psk: pass, + positionEnabled: + channel?.settings?.moduleSettings?.positionPrecision !== undefined && - channel?.settings?.moduleSettings?.positionPrecision > 0, - preciseLocation: - channel?.settings?.moduleSettings?.positionPrecision === 32, - positionPrecision: - channel?.settings?.moduleSettings?.positionPrecision === undefined - ? 10 - : channel?.settings?.moduleSettings?.positionPrecision, + channel?.settings?.moduleSettings?.positionPrecision > 0, + preciseLocation: + channel?.settings?.moduleSettings?.positionPrecision === 32, + positionPrecision: + channel?.settings?.moduleSettings?.positionPrecision === undefined + ? 10 + : channel?.settings?.moduleSettings?.positionPrecision, + }, }, - }, - }} - fieldGroups={[ - { - label: "Channel Settings", - description: "Crypto, MQTT & misc settings", - fields: [ - { - type: "select", - name: "role", - label: "Role", - disabled: channel.index === 0, - description: - "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", - properties: { - enumValue: - channel.index === 0 - ? { PRIMARY: 1 } - : { DISABLED: 0, SECONDARY: 2 }, + }} + fieldGroups={[ + { + label: "Channel Settings", + description: "Crypto, MQTT & misc settings", + fields: [ + { + type: "select", + name: "role", + label: "Role", + disabled: channel.index === 0, + description: + "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", + properties: { + enumValue: + channel.index === 0 + ? { PRIMARY: 1 } + : { DISABLED: 0, SECONDARY: 2 }, + }, }, - }, - { - type: "passwordGenerator", - name: "settings.psk", - label: "pre-Shared Key", - description: "256, 128, or 8 bit PSKs allowed", - validationText: validationText, - devicePSKBitCount: bitCount ?? 0, - inputChange: inputChangeEvent, - selectChange: selectChangeEvent, - buttonClick: clickEvent, - hide: true, - properties: { - value: pass, + { + type: "passwordGenerator", + name: "settings.psk", + label: "Pre-Shared Key", + description: "256, 128, or 8 bit PSKs allowed", + validationText: validationText, + devicePSKBitCount: bitCount ?? 0, + inputChange: inputChangeEvent, + selectChange: selectChangeEvent, + actionButtons: [{ text: 'Generate', variant: 'success', onClick: preSharedClickEvent }], + hide: true, + properties: { + value: pass, + }, }, - }, - { - type: "text", - name: "settings.name", - label: "Name", - description: - "A unique name for the channel <12 bytes, leave blank for default", - }, - { - type: "toggle", - name: "settings.uplinkEnabled", - label: "Uplink Enabled", - description: "Send messages from the local mesh to MQTT", - }, - { - type: "toggle", - name: "settings.downlinkEnabled", - label: "Downlink Enabled", - description: "Send messages from MQTT to the local mesh", - }, - { - type: "toggle", - name: "settings.positionEnabled", - label: "Allow Position Requests", - description: "Send position to channel", - }, - { - type: "toggle", - name: "settings.preciseLocation", - label: "Precise Location", - description: "Send precise location to channel", - }, - { - type: "select", - name: "settings.positionPrecision", - label: "Approximate Location", - description: - "If not sharing precise location, position shared on channel will be accurate within this distance", - properties: { - enumValue: - config.display?.units === 0 - ? { + { + type: "text", + name: "settings.name", + label: "Name", + description: + "A unique name for the channel <12 bytes, leave blank for default", + }, + { + type: "toggle", + name: "settings.uplinkEnabled", + label: "Uplink Enabled", + description: "Send messages from the local mesh to MQTT", + }, + { + type: "toggle", + name: "settings.downlinkEnabled", + label: "Downlink Enabled", + description: "Send messages from MQTT to the local mesh", + }, + { + type: "toggle", + name: "settings.positionEnabled", + label: "Allow Position Requests", + description: "Send position to channel", + }, + { + type: "toggle", + name: "settings.preciseLocation", + label: "Precise Location", + description: "Send precise location to channel", + }, + { + type: "select", + name: "settings.positionPrecision", + label: "Approximate Location", + description: + "If not sharing precise location, position shared on channel will be accurate within this distance", + properties: { + enumValue: + config.display?.units === 0 + ? { "Within 23 km": 10, "Within 12 km": 11, "Within 5.8 km": 12, @@ -188,7 +196,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { "Within 90 m": 18, "Within 50 m": 19, } - : { + : { "Within 15 miles": 10, "Within 7.3 miles": 11, "Within 3.6 miles": 12, @@ -200,11 +208,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { "Within 300 feet": 18, "Within 150 feet": 19, }, + }, }, - }, - ], - }, - ]} - /> + ], + }, + ]} + /> + setPreSharedDialogOpen(false)} + onSubmit={() => preSharedKeyRegenerate()} + /> + ); }; diff --git a/src/components/PageComponents/Config/Security.tsx b/src/components/PageComponents/Config/Security.tsx index cf675751..d6e76cc9 100644 --- a/src/components/PageComponents/Config/Security.tsx +++ b/src/components/PageComponents/Config/Security.tsx @@ -12,7 +12,7 @@ import { Eye, EyeOff } from "lucide-react"; import { useState } from "react"; export const Security = (): JSX.Element => { - const { config, nodes, hardware, setWorkingConfig } = useDevice(); + const { config, nodes, hardware, setWorkingConfig, setDialogOpen } = useDevice(); const [privateKey, setPrivateKey] = useState( fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), @@ -31,7 +31,7 @@ export const Security = (): JSX.Element => { ); const [adminKeyValidationText, setAdminKeyValidationText] = useState(); - const [dialogOpen, setDialogOpen] = useState(false); + const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState(false); const onSubmit = (data: SecurityValidation) => { if (privateKeyValidationText || adminKeyValidationText) return; @@ -71,9 +71,13 @@ export const Security = (): JSX.Element => { }; const privateKeyClickEvent = () => { - setDialogOpen(true); + setPrivateKeyDialogOpen(true); }; + const pkiBackupClickEvent = () => { + setDialogOpen("pkiBackup", true); + } + const pkiRegenerate = () => { const privateKey = getX25519PrivateKey(); const publicKey = getX25519PublicKey(privateKey); @@ -86,7 +90,7 @@ export const Security = (): JSX.Element => { setPrivateKeyValidationText, ); - setDialogOpen(false); + setPrivateKeyDialogOpen(false); }; const privateKeyInputChangeEvent = ( @@ -149,7 +153,18 @@ export const Security = (): JSX.Element => { inputChange: privateKeyInputChangeEvent, selectChange: privateKeySelectChangeEvent, hide: !privateKeyVisible, - buttonClick: privateKeyClickEvent, + actionButtons: [ + { + text: "Generate", + onClick: privateKeyClickEvent, + variant: "success", + }, + { + text: "Backup Key", + onClick: pkiBackupClickEvent, + variant: "subtle", + }, + ], properties: { value: privateKey, action: { @@ -228,8 +243,8 @@ export const Security = (): JSX.Element => { ]} /> setDialogOpen(false)} + open={privateKeyDialogOpen} + onOpenChange={() => setPrivateKeyDialogOpen(false)} onSubmit={() => pkiRegenerate()} /> diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index 39418551..e1b2d704 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -35,9 +35,11 @@ const buttonVariants = cva( }, ); +export type ButtonVariant = VariantProps["variant"]; + export interface ButtonProps extends React.ButtonHTMLAttributes, - VariantProps {} + VariantProps { } const Button = React.forwardRef( ({ className, variant, size, ...props }, ref) => { diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index cb8fea26..c983b8ef 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { Button } from "@components/UI/Button.tsx"; +import { Button, type ButtonVariant } from "@components/UI/Button.tsx"; import { Input } from "@components/UI/Input.tsx"; import { Select, @@ -16,11 +16,15 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { devicePSKBitCount?: number; value: string; variant: "default" | "invalid"; - buttonText?: string; + actionButtons: { + text: string; + onClick: React.MouseEventHandler; + variant: ButtonVariant; + className?: string; + }[]; bits?: { text: string; value: string; key: string }[]; selectChange: (event: string) => void; inputChange: (event: React.ChangeEvent) => void; - buttonClick: React.MouseEventHandler; action?: { icon: LucideIcon; onClick: () => void; @@ -35,7 +39,7 @@ const Generator = React.forwardRef( devicePSKBitCount, variant, value, - buttonText, + actionButtons, bits = [ { text: "256 bit", value: "32", key: "bit256" }, { text: "128 bit", value: "16", key: "bit128" }, @@ -43,7 +47,6 @@ const Generator = React.forwardRef( ], selectChange, inputChange, - buttonClick, action, disabled, ...props @@ -93,15 +96,21 @@ const Generator = React.forwardRef( ))} - +
+ {actionButtons?.map(({ text, onClick, variant, className }) => ( + + ))} +
); }, diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index a716a85b..bd407611 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -25,7 +25,8 @@ export type DialogVariant = | "shutdown" | "reboot" | "deviceName" - | "nodeRemoval"; + | "nodeRemoval" + | "pkiBackup"; export interface Device { id: number; @@ -60,6 +61,7 @@ export interface Device { reboot: boolean; deviceName: boolean; nodeRemoval: boolean; + pkiBackup: boolean; }; setStatus: (status: Types.DeviceStatusEnum) => void; @@ -142,6 +144,7 @@ export const useDeviceStore = create((set, get) => ({ reboot: false, deviceName: false, nodeRemoval: false, + pkiBackup: false, }, pendingSettingsChanges: false, messageDraft: "",