Compare commits
41
Commits
latest
...
pre-release
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e12b27566 | ||
|
|
989fad7e17 | ||
|
|
f7a2e5f76b | ||
|
|
2b81fc47e2 | ||
|
|
b522113cd7 | ||
|
|
fce642c24e | ||
|
|
5e72510bef | ||
|
|
88efdc4758 | ||
|
|
41acc4d25d | ||
|
|
d10c010b9a | ||
|
|
80ab44c8db | ||
|
|
7895df2d9f | ||
|
|
c780437355 | ||
|
|
9d4aa05316 | ||
|
|
354d04592b | ||
|
|
9bea6870bb | ||
|
|
d0bd02980d | ||
|
|
7d1135b9dc | ||
|
|
f66332b3e3 | ||
|
|
05a6b6293e | ||
|
|
b9a8a2ba6c | ||
|
|
c16070f02b | ||
|
|
3e6653a98f | ||
|
|
59126ca939 | ||
|
|
4bde402a53 | ||
|
|
4cf91272de | ||
|
|
c711c39aa9 | ||
|
|
c4342f1a2b | ||
|
|
afc45588fa | ||
|
|
22cd5aa88d | ||
|
|
8c4c8a760e | ||
|
|
0911df6b0d | ||
|
|
06c20fa950 | ||
|
|
22d900a831 | ||
|
|
c6cc5e5e6f | ||
|
|
f0d8db1c87 | ||
|
|
8c37be4af3 | ||
|
|
076dae80b7 | ||
|
|
7d4001ea9d | ||
|
|
6c1f140ad1 | ||
|
|
4c4be2e18f |
@@ -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 }}"
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
@@ -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",
|
||||||
|
|||||||
Generated
+16
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user