41 Commits
Author SHA1 Message Date
Hunter ThornsberryandGitHub 2e12b27566 Merge pull request #277 from KomelT/feature/security-tab
Update key generation
2024-09-08 18:13:42 -04:00
Hunter Thornsberry 989fad7e17 biome 2024-09-07 20:24:45 -04:00
Hunter Thornsberry f7a2e5f76b Invert adminKey disabledBy 2024-09-07 20:24:18 -04:00
Hunter Thornsberry 2b81fc47e2 disable the public key input 2024-09-07 20:19:51 -04:00
Hunter Thornsberry b522113cd7 bad merge on Security.tsx 2024-09-07 20:16:29 -04:00
Hunter ThornsberryandGitHub fce642c24e Merge branch 'pki' into feature/security-tab 2024-09-07 19:54:57 -04:00
Tilen Komel 5e72510bef Removed submit button 2024-09-06 09:33:13 +02:00
Hunter Thornsberry 88efdc4758 Merge branch 'master' into pki 2024-09-05 19:26:32 -04:00
Hunter ThornsberryandGitHub 41acc4d25d Merge pull request #289 from Hunter275/gh-actions
GitHub Actions: Releases
2024-09-04 16:15:41 -04:00
Tilen Komel d10c010b9a Dont disable PKI on Allow Legacy Admin 2024-09-04 18:50:49 +02:00
Hunter Thornsberry 80ab44c8db don't release on release 2024-09-04 00:39:12 -04:00
Hunter Thornsberry 7895df2d9f Stop release on ci 2024-09-04 00:35:22 -04:00
Hunter ThornsberryandGitHub c780437355 Merge pull request #286 from TheCyberRonin/feature/provide-compressed-build-during-pr
feat: Provide compressed build during PR GitHub Action
2024-09-04 00:12:26 -04:00
Tilen Komel 9d4aa05316 Updated pki regenerate dialog 2024-09-02 15:45:58 +02:00
Tilen Komel 354d04592b Fix build 2024-09-02 15:05:04 +02:00
Tilen Komel 9bea6870bb Add submit button to work 2024-09-02 13:11:42 +02:00
Tilen Komel d0bd02980d Update private key generation 2024-09-02 13:10:59 +02:00
Hunter ThornsberryandGitHub 7d1135b9dc Merge pull request #288 from mrfyda/master
Change 'powerScreenEnabled' config type to toggle
2024-09-01 19:00:25 -04:00
Tilen Komel f66332b3e3 Handle undefined booleans 2024-08-31 15:58:12 +02:00
Rafael Cortês 05a6b6293e Change 'powerScreenEnabled' config type to toggle 2024-08-31 10:51:53 +01:00
Hunter ThornsberryandGitHub b9a8a2ba6c Merge pull request #287 from TheCyberRonin/feature/scope-ci-to-push-on-master
feat: Only do `CI` GHA on push to `master`
2024-08-30 12:31:09 -04:00
Ronin c16070f02b feat: Only do CI GHA on push to master 2024-08-29 21:54:58 -04:00
Ronin 3e6653a98f chore: Fix typo in package name for uploading 2024-08-29 21:31:04 -04:00
Ronin 59126ca939 feat: Add a build step for build.tar 2024-08-29 21:27:37 -04:00
Tilen Komel 4bde402a53 Add key generation confirmation 2024-08-29 23:19:12 +02:00
Tilen Komel 4cf91272de Removed admin key generation from security 2024-08-29 23:19:12 +02:00
Tilen Komel c711c39aa9 Add inputChange to FormInpit 2024-08-29 23:19:12 +02:00
Tilen Komel c4342f1a2b Update passwordGenerator disabled 2024-08-29 23:19:12 +02:00
Tilen Komel afc45588fa Fixed security key generation 2024-08-29 23:19:12 +02:00
Tilen Komel 22cd5aa88d Add key generation 2024-08-29 23:19:12 +02:00
Tilen Komel 8c4c8a760e Add @noble/curves 2024-08-29 23:19:11 +02:00
Tilen Komel 0911df6b0d Add dynamic bit to password generator 2024-08-29 23:16:02 +02:00
Tilen Komel 06c20fa950 Add dynamic value to input 2024-08-29 23:16:02 +02:00
Hunter ThornsberryandGitHub 22d900a831 Merge pull request #278 from Hunter275/dashboard-footer
flex-grow to make footer stick to bottom
2024-08-26 15:33:40 -04:00
Hunter Thornsberry c6cc5e5e6f biome 2024-08-26 14:32:18 -04:00
Hunter Thornsberry f0d8db1c87 flex-grow to make footer stick to bottom 2024-08-26 14:10:43 -04:00
Hunter ThornsberryandGitHub 8c37be4af3 Merge pull request #274 from rcarteraz/add-footer 2024-08-24 11:50:51 -04:00
Hunter Thornsberry 076dae80b7 first working version 2024-08-23 18:58:22 -04:00
Hunter Thornsberry 7d4001ea9d Update footer and initial groundword 2024-08-23 18:40:17 -04:00
rcarteraz 6c1f140ad1 oops -- helps if you use the full link 2024-08-19 13:31:22 -07:00
rcarteraz 4c4be2e18f temp solution 2024-08-19 13:20:56 -07:00
21 changed files with 625 additions and 440 deletions
+4 -40
View File
@@ -1,6 +1,9 @@
name: CI name: CI
on: push on:
push:
branches:
- master
permissions: permissions:
contents: write contents: write
@@ -21,42 +24,3 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Package Output
run: pnpm package
- name: Upload Artifact
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "latest"
prerelease: false
files: |
./dist/build.tar
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
+9
View File
@@ -19,3 +19,12 @@ jobs:
- name: Build Package - name: Build Package
run: pnpm build run: pnpm build
- name: Compress build
run: pnpm package
- name: Archive compressed build
uses: actions/upload-artifact@v4
with:
name: build
path: dist/build.tar
+55
View File
@@ -0,0 +1,55 @@
name: 'Release'
on:
release:
types: [released]
permissions:
contents: write
packages: write
jobs:
build-and-package:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Install Dependencies
run: pnpm install
- name: Build Package
run: pnpm build
- name: Package Output
run: pnpm package
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Buildah Build
id: build-container
uses: redhat-actions/buildah-build@v2
with:
containerfiles: |
./Containerfile
image: ${{github.event.repository.full_name}}
tags: latest ${{ github.sha }}
oci: true
platforms: linux/amd64, linux/arm64
- name: Push To Registry
id: push-to-registry
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-container.outputs.image }}
tags: ${{ steps.build-container.outputs.tags }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Print image url
run: echo "Image pushed to ${{ steps.push-to-registry.outputs.registry-paths }}"
+1
View File
@@ -24,6 +24,7 @@
"@bufbuild/protobuf": "^1.10.0", "@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2", "@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.7-1", "@meshtastic/js": "2.3.7-1",
"@noble/curves": "^1.5.0",
"@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",
+16
View File
@@ -17,6 +17,9 @@ importers:
'@meshtastic/js': '@meshtastic/js':
specifier: 2.3.7-1 specifier: 2.3.7-1
version: 2.3.7-1 version: 2.3.7-1
'@noble/curves':
specifier: ^1.5.0
version: 1.5.0
'@radix-ui/react-accordion': '@radix-ui/react-accordion':
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -584,6 +587,13 @@ packages:
'@meshtastic/js@2.3.7-1': '@meshtastic/js@2.3.7-1':
resolution: {integrity: sha512-pv+Xk6HkKrScCrQp31k5QOUYozabXn6NhXN7c7Cc9ysG94U1wGtfueRbEbFxXCHO3JshNz0CdE1FcSMnrLMjsQ==} resolution: {integrity: sha512-pv+Xk6HkKrScCrQp31k5QOUYozabXn6NhXN7c7Cc9ysG94U1wGtfueRbEbFxXCHO3JshNz0CdE1FcSMnrLMjsQ==}
'@noble/curves@1.5.0':
resolution: {integrity: sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==}
'@noble/hashes@1.4.0':
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
engines: {node: '>= 16'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3485,6 +3495,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- buffer - buffer
'@noble/curves@1.5.0':
dependencies:
'@noble/hashes': 1.4.0
'@noble/hashes@1.4.0': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
+6 -1
View File
@@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
import { DialogManager } from "@components/Dialog/DialogManager.js"; import { DialogManager } from "@components/Dialog/DialogManager.js";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
import { Toaster } from "@components/Toaster.js"; import { Toaster } from "@components/Toaster.js";
import Footer from "@components/UI/Footer.js";
import { ThemeController } from "@components/generic/ThemeController.js"; import { ThemeController } from "@components/generic/ThemeController.js";
import { useAppStore } from "@core/stores/appStore.js"; import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js"; import { useDeviceStore } from "@core/stores/deviceStore.js";
@@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
<PageRouter /> <PageRouter />
</div> </div>
) : ( ) : (
<Dashboard /> <>
<Dashboard />
<div className="flex flex-grow" />
<Footer />
</>
)} )}
</div> </div>
</div> </div>
@@ -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>
);
};
+7 -3
View File
@@ -4,11 +4,14 @@ import type {
} from "@components/Form/DynamicForm.js"; } from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js"; import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> { export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password"; type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
properties?: { properties?: {
value?: string;
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
step?: number; step?: number;
@@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
type={field.type} type={field.type}
step={field.properties?.step} step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value} value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) => onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange( onChange(
field.type === "number" field.type === "number"
? Number.parseFloat(e.target.value) ? Number.parseFloat(e.target.value)
: e.target.value, : e.target.value,
) );
} }}
{...field.properties} {...field.properties}
{...rest} {...rest}
disabled={disabled} disabled={disabled}
@@ -9,6 +9,7 @@ import { Controller, type FieldValues } from "react-hook-form";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> { export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator"; type: "passwordGenerator";
hide?: boolean; hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number; devicePSKBitCount: number;
inputChange: ChangeEventHandler; inputChange: ChangeEventHandler;
selectChange: (event: string) => void; selectChange: (event: string) => void;
@@ -28,6 +29,7 @@ export function PasswordGenerator<T extends FieldValues>({
<Generator <Generator
hide={field.hide} hide={field.hide}
devicePSKBitCount={field.devicePSKBitCount} devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange} inputChange={field.inputChange}
selectChange={field.selectChange} selectChange={field.selectChange}
buttonClick={field.buttonClick} buttonClick={field.buttonClick}
+167 -162
View File
@@ -1,9 +1,13 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.js"; 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 type { SecurityValidation } from "@app/validation/config/security.js";
import { useDevice } from "@core/stores/deviceStore.js"; import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -15,7 +19,7 @@ export const Security = (): JSX.Element => {
); );
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false); const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>( const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 16, config.security?.privateKey.length ?? 32,
); );
const [privateKeyValidationText, setPrivateKeyValidationText] = const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>(); useState<string>();
@@ -25,12 +29,9 @@ export const Security = (): JSX.Element => {
const [adminKey, setAdminKey] = useState<string>( const [adminKey, setAdminKey] = useState<string>(
fromByteArray(config.security?.adminKey ?? new Uint8Array(0)), fromByteArray(config.security?.adminKey ?? new Uint8Array(0)),
); );
const [adminKeyVisible, setAdminKeyVisible] = useState<boolean>(false);
const [adminKeyBitCount, setAdminKeyBitCount] = useState<number>(
config.security?.adminKey.length ?? 16,
);
const [adminKeyValidationText, setAdminKeyValidationText] = const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>(); useState<string>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => { const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return; if (privateKeyValidationText || adminKeyValidationText) return;
@@ -50,191 +51,195 @@ export const Security = (): JSX.Element => {
); );
}; };
const clickEvent = ( const validateKey = (
setKey: (value: React.SetStateAction<string>) => void,
bitCount: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => void,
) => {
setKey(
btoa(
cryptoRandomString({
length: bitCount ?? 0,
type: "alphanumeric",
}),
),
);
setValidationText(undefined);
};
const validatePass = (
input: string, input: string,
count: number, count: number,
setValidationText: ( setValidationText: (
value: React.SetStateAction<string | undefined>, value: React.SetStateAction<string | undefined>,
) => void, ) => void,
) => { ) => {
if (input.length % 4 !== 0 || toByteArray(input).length !== count) { 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.`); setValidationText(`Please enter a valid ${count * 8} bit PSK.`);
} else {
setValidationText(undefined);
} }
}; };
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 = ( const privateKeyInputChangeEvent = (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
) => { ) => {
const psk = e.currentTarget?.value; const privateKeyB64String = e.target.value;
setPrivateKey(psk); setPrivateKey(privateKeyB64String);
validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText); validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
}; };
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => { const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value; const psk = e.currentTarget?.value;
setAdminKey(psk); setAdminKey(psk);
validatePass(psk, privateKeyBitCount, setAdminKeyValidationText); validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
}; };
const privateKeySelectChangeEvent = (e: string) => { const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e); const count = Number.parseInt(e);
setPrivateKeyBitCount(count); setPrivateKeyBitCount(count);
validatePass(privateKey, count, setPrivateKeyValidationText); validateKey(privateKey, count, setPrivateKeyValidationText);
};
const adminKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setAdminKeyBitCount(count);
validatePass(privateKey, count, setAdminKeyValidationText);
}; };
return ( return (
<DynamicForm<SecurityValidation> <>
onSubmit={onSubmit} <DynamicForm<SecurityValidation>
defaultValues={{ onSubmit={onSubmit}
...config.security, submitType="onChange"
...{ defaultValues={{
adminKey: adminKey, ...config.security,
privateKey: privateKey, ...{
publicKey: publicKey, adminKey: adminKey,
}, privateKey: privateKey,
}} publicKey: publicKey,
fieldGroups={[ adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
{ isManaged: config.security?.isManaged ?? false,
label: "Security Settings", bluetoothLoggingEnabled:
description: "Settings for the Security configuration", config.security?.bluetoothLoggingEnabled ?? false,
fields: [ debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
{ serialEnabled: config.security?.serialEnabled ?? false,
type: "passwordGenerator", },
name: "privateKey", }}
label: "Private Key", fieldGroups={[
description: "Used to create a shared key with a remote device", {
validationText: privateKeyValidationText, label: "Security Settings",
devicePSKBitCount: privateKeyBitCount, description: "Settings for the Security configuration",
inputChange: privateKeyInputChangeEvent, fields: [
selectChange: privateKeySelectChangeEvent, {
hide: !privateKeyVisible, type: "passwordGenerator",
buttonClick: () => name: "privateKey",
clickEvent( label: "Private Key",
setPrivateKey, description: "Used to create a shared key with a remote device",
privateKeyBitCount, bits: [{ text: "256 bit", value: "32", key: "bit256" }],
setPrivateKeyValidationText, validationText: privateKeyValidationText,
), devicePSKBitCount: privateKeyBitCount,
disabledBy: [ inputChange: privateKeyInputChangeEvent,
{ selectChange: privateKeySelectChangeEvent,
fieldName: "adminChannelEnabled", hide: !privateKeyVisible,
invert: true, buttonClick: privateKeyClickEvent,
}, properties: {
], value: privateKey,
properties: { action: {
value: privateKey, icon: privateKeyVisible ? EyeOff : Eye,
action: { onClick: () => setPrivateKeyVisible(!privateKeyVisible),
icon: privateKeyVisible ? EyeOff : Eye, },
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
}, },
}, },
}, {
{ type: "text",
type: "text", name: "publicKey",
name: "publicKey", label: "Public Key",
label: "Public Key", disabled: true,
disabled: true, description:
description: "Sent out to other nodes on the mesh to allow them to compute a shared secret key",
"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: "passwordGenerator",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
devicePSKBitCount: adminKeyBitCount,
inputChange: adminKeyInputChangeEvent,
selectChange: adminKeySelectChangeEvent,
hide: !adminKeyVisible,
buttonClick: () =>
clickEvent(
setAdminKey,
adminKeyBitCount,
setAdminKeyValidationText,
),
disabledBy: [{ fieldName: "adminChannelEnabled" }],
properties: {
value: adminKey,
action: {
icon: adminKeyVisible ? EyeOff : Eye,
onClick: () => setAdminKeyVisible(!adminKeyVisible),
}, },
}, },
}, ],
], },
}, {
{ label: "Admin Settings",
label: "Logging Settings", description: "Settings for Admin",
description: "Settings for Logging", fields: [
fields: [ {
{ type: "toggle",
type: "toggle", name: "adminChannelEnabled",
name: "bluetoothLoggingEnabled", label: "Allow Legacy Admin",
label: "Allow Bluetooth Logging", description:
description: "Enables device (serial style logs) over Bluetooth", "Allow incoming device control over the insecure legacy admin channel",
}, },
{ {
type: "toggle", type: "toggle",
name: "debugLogApiEnabled", name: "isManaged",
label: "Enable Debug Log API", label: "Managed",
description: "Output live debug logging over serial", description:
}, 'If true, device is considered to be "managed" by a mesh administrator via admin messages',
{ },
type: "toggle", {
name: "serialEnabled", type: "text",
label: "Serial Output Enabled", name: "adminKey",
description: "Serial Console over the Stream API", 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: "bluetoothLoggingEnabled",
label: "Allow Bluetooth Logging",
description:
"Enables device (serial style logs) over Bluetooth",
},
{
type: "toggle",
name: "debugLogApiEnabled",
label: "Enable Debug Log API",
description: "Output live debug logging over serial",
},
{
type: "toggle",
name: "serialEnabled",
label: "Serial Output Enabled",
description: "Serial Console over the Stream API",
},
],
},
]}
/>
<PkiRegenerateDialog
open={dialogOpen}
onOpenChange={() => setDialogOpen(false)}
onSubmit={() => pkiRegenerate()}
/>
</>
); );
}; };
@@ -68,7 +68,7 @@ export const ChannelChat = ({
)} )}
</div> </div>
</div> </div>
<div className="p-3"> <div className="pl-3 pr-3 pt-3 pb-1">
<MessageInput to={to} channel={channel} /> <MessageInput to={to} channel={channel} />
</div> </div>
</div> </div>
@@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
description: "How often to send Power data over the mesh", description: "How often to send Power data over the mesh",
}, },
{ {
type: "text", type: "toggle",
name: "powerScreenEnabled", name: "powerScreenEnabled",
label: "Power Screen Enabled", label: "Power Screen Enabled",
description: "Enable the Power Telemetry Screen", description: "Enable the Power Telemetry Screen",
+35 -31
View File
@@ -1,5 +1,6 @@
import { cn } from "@app/core/utils/cn.js"; import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, type LucideIcon } from "lucide-react"; import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer";
export interface PageLayoutProps { export interface PageLayoutProps {
label: string; label: string;
@@ -18,40 +19,43 @@ export const PageLayout = ({
children, children,
}: PageLayoutProps): JSX.Element => { }: PageLayoutProps): JSX.Element => {
return ( return (
<div className="relative flex h-full w-full flex-col"> <>
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4"> <div className="relative flex h-full w-full flex-col">
<button <div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
type="button" <button
className="pl-4 transition-all hover:text-accent md:hidden" type="button"
> className="pl-4 transition-all hover:text-accent md:hidden"
<AlignLeftIcon /> >
</button> <AlignLeftIcon />
<div className="flex flex-1 items-center justify-between px-4 md:px-0"> </button>
<div className="flex w-full items-center"> <div className="flex flex-1 items-center justify-between px-4 md:px-0">
<span className="w-full text-lg font-medium">{label}</span> <div className="flex w-full items-center">
<div className="flex justify-end space-x-4"> <span className="w-full text-lg font-medium">{label}</span>
{actions?.map((action, index) => ( <div className="flex justify-end space-x-4">
<button {actions?.map((action, index) => (
key={action.icon.name} <button
type="button" key={action.icon.name}
className="transition-all hover:text-accent" type="button"
onClick={action.onClick} className="transition-all hover:text-accent"
> onClick={action.onClick}
<action.icon /> >
</button> <action.icon />
))} </button>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "pl-3 pr-3 ",
)}
>
{children}
<Footer />
</div>
</div> </div>
<div </>
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "p-3",
)}
>
{children}
</div>
</div>
); );
}; };
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {}
const Footer = React.forwardRef<HTMLElement, FooterProps>(
({ className, ...props }, ref) => {
return (
<footer
className={`flex flex- justify-center p-2 ${className}`}
style={{
backgroundColor: "var(--backgroundPrimary)",
color: "var(--textPrimary)",
}}
>
<p>
<a
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Powered by ▲ Vercel
</a>{" "}
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "}
<a
href="https://meshtastic.org/docs/legal"
className="hover:underline"
style={{ color: "var(--link)" }}
>
Legal Information
</a>
</p>
</footer>
);
},
);
export default Footer;
+29 -9
View File
@@ -17,6 +17,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
value: string; value: string;
variant: "default" | "invalid"; variant: "default" | "invalid";
buttonText?: string; buttonText?: string;
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void; selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void; inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonClick: React.MouseEventHandler<HTMLButtonElement>; buttonClick: React.MouseEventHandler<HTMLButtonElement>;
@@ -35,6 +36,11 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
variant, variant,
value, value,
buttonText, buttonText,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
{ text: "8 bit", value: "1", key: "bit8" },
],
selectChange, selectChange,
inputChange, inputChange,
buttonClick, buttonClick,
@@ -44,6 +50,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
}, },
ref, 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 ( return (
<> <>
<Input <Input
@@ -54,30 +75,29 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
onChange={inputChange} onChange={inputChange}
action={action} action={action}
disabled={disabled} disabled={disabled}
ref={inputRef}
/> />
<Select <Select
value={devicePSKBitCount?.toString()} value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)} onValueChange={(e) => selectChange(e)}
disabled={disabled}
> >
<SelectTrigger className="!max-w-max"> <SelectTrigger className="!max-w-max">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem key="bit256" value="32"> {bits.map(({ text, value, key }) => (
256 bit <SelectItem key={key} value={value}>
</SelectItem> {text}
<SelectItem key="bit128" value="16"> </SelectItem>
128 bit ))}
</SelectItem>
<SelectItem key="bit8" value="1">
8 bit
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
type="button" type="button"
variant="success" variant="success"
onClick={buttonClick} onClick={buttonClick}
disabled={disabled}
{...props} {...props}
> >
{buttonText} {buttonText}
+2 -1
View File
@@ -31,7 +31,7 @@ export interface InputProps
} }
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, variant, prefix, suffix, action, ...props }, ref) => { ({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
{prefix && ( {prefix && (
@@ -45,6 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className, className,
inputVariants({ variant }), inputVariants({ variant }),
)} )}
value={value}
ref={ref} ref={ref}
{...props} {...props}
/> />
+1 -1
View File
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 dark:bg-slate-800", "inline-flex flex-wrap items-center rounded-md bg-slate-100 p-1 mt-2 dark:bg-slate-800",
className, className,
)} )}
{...props} {...props}
+15
View File
@@ -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);
}
+74 -72
View File
@@ -21,79 +21,81 @@ export const Dashboard = () => {
const devices = useMemo(() => getDevices(), [getDevices]); const devices = useMemo(() => getDevices(), [getDevices]);
return ( return (
<div className="flex flex-col gap-3 p-3"> <>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 p-3">
<div className="space-y-1"> <div className="flex items-center justify-between">
<H3>Connected Devices</H3> <div className="space-y-1">
<Subtle>Manage, connect and disconnect devices</Subtle> <H3>Connected Devices</H3>
<Subtle>Manage, connect and disconnect devices</Subtle>
</div>
</div>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul className="grow divide-y divide-gray-200">
{devices.map((device) => {
return (
<li key={device.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.nodes.get(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
BLE
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
Serial
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
Network
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<UsersIcon
size={20}
className="text-gray-400"
aria-hidden="true"
/>
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<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>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
New Connection
</Button>
</div>
)}
</div> </div>
</div> </div>
</>
<Separator />
<div className="flex h-[450px] rounded-md border border-dashed border-slate-200 p-3 dark:border-slate-700">
{devices.length ? (
<ul className="grow divide-y divide-gray-200">
{devices.map((device) => {
return (
<li key={device.id}>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="truncate text-sm font-medium text-accent">
{device.nodes.get(device.hardware.myNodeNum)?.user
?.longName ?? "UNK"}
</p>
<div className="inline-flex w-24 justify-center gap-2 rounded-full bg-slate-100 py-1 text-xs font-semibold text-slate-900 transition-colors hover:bg-slate-700 hover:text-slate-50">
{device.connection?.connType === "ble" && (
<>
<BluetoothIcon size={16} />
BLE
</>
)}
{device.connection?.connType === "serial" && (
<>
<UsbIcon size={16} />
Serial
</>
)}
{device.connection?.connType === "http" && (
<>
<NetworkIcon size={16} />
Network
</>
)}
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="flex gap-2 text-sm text-gray-500">
<UsersIcon
size={20}
className="text-gray-400"
aria-hidden="true"
/>
{device.nodes.size === 0 ? 0 : device.nodes.size - 1}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<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>
<Button
className="gap-2"
onClick={() => setConnectDialogOpen(true)}
>
<PlusIcon size={16} />
New Connection
</Button>
</div>
)}
</div>
</div>
); );
}; };
+54 -52
View File
@@ -67,60 +67,62 @@ export const MessagesPage = (): JSX.Element => {
))} ))}
</SidebarSection> </SidebarSection>
</Sidebar> </Sidebar>
<PageLayout <div className="flex flex-col flex-grow">
label={`Messages: ${ <PageLayout
chatType === "broadcast" && currentChannel label={`Messages: ${
? getChannelName(currentChannel) chatType === "broadcast" && currentChannel
: chatType === "direct" && nodes.get(activeChat) ? getChannelName(currentChannel)
? nodes.get(activeChat)?.user?.longName ?? "Unknown" : chatType === "direct" && nodes.get(activeChat)
: "Loading..." ? nodes.get(activeChat)?.user?.longName ?? "Unknown"
}`} : "Loading..."
actions={ }`}
chatType === "direct" actions={
? [ chatType === "direct"
{ ? [
icon: WaypointsIcon, {
async onClick() { icon: WaypointsIcon,
const targetNode = nodes.get(activeChat)?.num; async onClick() {
if (targetNode === undefined) return; const targetNode = nodes.get(activeChat)?.num;
toast({ if (targetNode === undefined) return;
title: "Sending Traceroute, please wait...",
});
await connection?.traceRoute(targetNode).then(() =>
toast({ toast({
title: "Traceroute sent.", title: "Sending Traceroute, please wait...",
}), });
); await connection?.traceRoute(targetNode).then(() =>
toast({
title: "Traceroute sent.",
}),
);
},
}, },
}, ]
] : []
: [] }
} >
> {allChannels.map(
{allChannels.map( (channel) =>
(channel) => activeChat === channel.index && (
activeChat === channel.index && ( <ChannelChat
<ChannelChat key={channel.index}
key={channel.index} to="broadcast"
to="broadcast" messages={messages.broadcast.get(channel.index)}
messages={messages.broadcast.get(channel.index)} channel={channel.index}
channel={channel.index} />
/> ),
), )}
)} {filteredNodes.map(
{filteredNodes.map( (node) =>
(node) => activeChat === node.num && (
activeChat === node.num && ( <ChannelChat
<ChannelChat key={node.num}
key={node.num} to={activeChat}
to={activeChat} messages={messages.direct.get(node.num)}
messages={messages.direct.get(node.num)} channel={Types.ChannelNumber.Primary}
channel={Types.ChannelNumber.Primary} traceroutes={traceroutes.get(node.num)}
traceroutes={traceroutes.get(node.num)} />
/> ),
), )}
)} </PageLayout>
</PageLayout> </div>
</> </>
); );
}; };
+70 -66
View File
@@ -1,3 +1,4 @@
import Footer from "@app/components/UI/Footer";
import { useAppStore } from "@app/core/stores/appStore"; import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js"; import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js"; import { Button } from "@components/UI/Button.js";
@@ -27,73 +28,76 @@ export const NodesPage = (): JSX.Element => {
return ( return (
<> <>
<Sidebar /> <Sidebar />
<div className="w-full overflow-y-auto"> <div className="flex flex-col w-full">
<Table <div className="overflow-y-auto h-full">
headings={[ <Table
{ title: "", type: "blank", sortable: false }, headings={[
{ title: "Name", type: "normal", sortable: true }, { title: "", type: "blank", sortable: false },
{ title: "Model", type: "normal", sortable: true }, { title: "Name", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true }, { title: "Model", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true }, { title: "MAC Address", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true },
{ title: "Connection", type: "normal", sortable: true }, { title: "SNR", type: "normal", sortable: true },
{ title: "Remove", type: "normal", sortable: false }, { title: "Connection", type: "normal", sortable: true },
]} { title: "Remove", type: "normal", sortable: false },
rows={filteredNodes.map((node) => [ ]}
<Hashicon key="icon" size={24} value={node.num.toString()} />, rows={filteredNodes.map((node) => [
<h1 key="header"> <Hashicon key="icon" size={24} value={node.num.toString()} />,
{node.user?.longName ?? <h1 key="header">
(node.user?.macaddr {node.user?.longName ??
? `Meshtastic ${base16 (node.user?.macaddr
.stringify(node.user?.macaddr.subarray(4, 6) ?? []) ? `Meshtastic ${base16
.toLowerCase()}` .stringify(node.user?.macaddr.subarray(4, 6) ?? [])
: `UNK: ${node.num}`)} .toLowerCase()}`
</h1>, : `UNK: ${node.num}`)}
</h1>,
<Mono key="model"> <Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>, </Mono>,
<Mono key="addr"> <Mono key="addr">
{base16 {base16
.stringify(node.user?.macaddr ?? []) .stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g) .match(/.{1,2}/g)
?.join(":") ?? "UNK"} ?.join(":") ?? "UNK"}
</Mono>, </Mono>,
<Fragment key="lastHeard"> <Fragment key="lastHeard">
{node.lastHeard === 0 ? ( {node.lastHeard === 0 ? (
<p>Never</p> <p>Never</p>
) : ( ) : (
<TimeAgo timestamp={node.lastHeard * 1000} /> <TimeAgo timestamp={node.lastHeard * 1000} />
)} )}
</Fragment>, </Fragment>,
<Mono key="snr"> <Mono key="snr">
{node.snr}db/ {node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw {(node.snr + 10) * 5}raw
</Mono>, </Mono>,
<Mono key="hops"> <Mono key="hops">
{node.lastHeard !== 0 {node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0 ? node.viaMqtt === false && node.hopsAway === 0
? "Direct" ? "Direct"
: `${node.hopsAway.toString()} ${ : `${node.hopsAway.toString()} ${
node.hopsAway > 1 ? "hops" : "hop" node.hopsAway > 1 ? "hops" : "hop"
} away` } away`
: "-"} : "-"}
{node.viaMqtt === true ? ", via MQTT" : ""} {node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>, </Mono>,
<Button <Button
key="remove" key="remove"
variant="destructive" variant="destructive"
onClick={() => { onClick={() => {
setNodeNumToBeRemoved(node.num); setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true); setDialogOpen("nodeRemoval", true);
}} }}
> >
<TrashIcon /> <TrashIcon />
Remove Remove
</Button>, </Button>,
])} ])}
/> />
</div>
<Footer />
</div> </div>
</> </>
); );