41 Commits

Author SHA1 Message Date
Hunter Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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 Thornsberry
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

View File

@@ -1,6 +1,9 @@
name: CI
on: push
on:
push:
branches:
- master
permissions:
contents: write
@@ -21,42 +24,3 @@ jobs:
- name: Build Package
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 }}"

View File

@@ -19,3 +19,12 @@ jobs:
- name: Build Package
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
.github/workflows/release.yml vendored Normal file
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 }}"

View File

@@ -24,6 +24,7 @@
"@bufbuild/protobuf": "^1.10.0",
"@emeraldpay/hashicon-react": "^0.5.2",
"@meshtastic/js": "2.3.7-1",
"@noble/curves": "^1.5.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",

16
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@meshtastic/js':
specifier: 2.3.7-1
version: 2.3.7-1
'@noble/curves':
specifier: ^1.5.0
version: 1.5.0
'@radix-ui/react-accordion':
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)
@@ -584,6 +587,13 @@ packages:
'@meshtastic/js@2.3.7-1':
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':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -3485,6 +3495,12 @@ snapshots:
transitivePeerDependencies:
- buffer
'@noble/curves@1.5.0':
dependencies:
'@noble/hashes': 1.4.0
'@noble/hashes@1.4.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5

View File

@@ -5,6 +5,7 @@ import { DeviceSelector } from "@components/DeviceSelector.js";
import { DialogManager } from "@components/Dialog/DialogManager.js";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.js";
import { Toaster } from "@components/Toaster.js";
import Footer from "@components/UI/Footer.js";
import { ThemeController } from "@components/generic/ThemeController.js";
import { useAppStore } from "@core/stores/appStore.js";
import { useDeviceStore } from "@core/stores/deviceStore.js";
@@ -40,7 +41,11 @@ export const App = (): JSX.Element => {
<PageRouter />
</div>
) : (
<Dashboard />
<>
<Dashboard />
<div className="flex flex-grow" />
<Footer />
</>
)}
</div>
</div>

View File

@@ -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>
);
};

View File

@@ -4,11 +4,14 @@ import type {
} from "@components/Form/DynamicForm.js";
import { Input } from "@components/UI/Input.js";
import type { LucideIcon } from "lucide-react";
import type { ChangeEventHandler } from "react";
import { Controller, type FieldValues } from "react-hook-form";
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
properties?: {
value?: string;
prefix?: string;
suffix?: string;
step?: number;
@@ -33,13 +36,14 @@ export function GenericInput<T extends FieldValues>({
type={field.type}
step={field.properties?.step}
value={field.type === "number" ? Number.parseFloat(value) : value}
onChange={(e) =>
onChange={(e) => {
if (field.inputChange) field.inputChange(e);
onChange(
field.type === "number"
? Number.parseFloat(e.target.value)
: e.target.value,
)
}
);
}}
{...field.properties}
{...rest}
disabled={disabled}

View File

@@ -9,6 +9,7 @@ import { Controller, type FieldValues } from "react-hook-form";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator";
hide?: boolean;
bits?: { text: string; value: string; key: string }[];
devicePSKBitCount: number;
inputChange: ChangeEventHandler;
selectChange: (event: string) => void;
@@ -28,6 +29,7 @@ export function PasswordGenerator<T extends FieldValues>({
<Generator
hide={field.hide}
devicePSKBitCount={field.devicePSKBitCount}
bits={field.bits}
inputChange={field.inputChange}
selectChange={field.selectChange}
buttonClick={field.buttonClick}

View File

@@ -1,9 +1,13 @@
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog";
import { DynamicForm } from "@app/components/Form/DynamicForm.js";
import {
getX25519PrivateKey,
getX25519PublicKey,
} from "@app/core/utils/x25519";
import type { SecurityValidation } from "@app/validation/config/security.js";
import { useDevice } from "@core/stores/deviceStore.js";
import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string";
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
@@ -15,7 +19,7 @@ export const Security = (): JSX.Element => {
);
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false);
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>(
config.security?.privateKey.length ?? 16,
config.security?.privateKey.length ?? 32,
);
const [privateKeyValidationText, setPrivateKeyValidationText] =
useState<string>();
@@ -25,12 +29,9 @@ export const Security = (): JSX.Element => {
const [adminKey, setAdminKey] = useState<string>(
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] =
useState<string>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return;
@@ -50,191 +51,195 @@ export const Security = (): JSX.Element => {
);
};
const clickEvent = (
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 = (
const validateKey = (
input: string,
count: number,
setValidationText: (
value: React.SetStateAction<string | undefined>,
) => 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.`);
} 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 = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const psk = e.currentTarget?.value;
setPrivateKey(psk);
validatePass(psk, privateKeyBitCount, setPrivateKeyValidationText);
const privateKeyB64String = e.target.value;
setPrivateKey(privateKeyB64String);
validateKey(
privateKeyB64String,
privateKeyBitCount,
setPrivateKeyValidationText,
);
const publicKey = getX25519PublicKey(toByteArray(privateKeyB64String));
setPublicKey(fromByteArray(publicKey));
};
const adminKeyInputChangeEvent = (e: React.ChangeEvent<HTMLInputElement>) => {
const psk = e.currentTarget?.value;
setAdminKey(psk);
validatePass(psk, privateKeyBitCount, setAdminKeyValidationText);
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText);
};
const privateKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setPrivateKeyBitCount(count);
validatePass(privateKey, count, setPrivateKeyValidationText);
};
const adminKeySelectChangeEvent = (e: string) => {
const count = Number.parseInt(e);
setAdminKeyBitCount(count);
validatePass(privateKey, count, setAdminKeyValidationText);
validateKey(privateKey, count, setPrivateKeyValidationText);
};
return (
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
},
}}
fieldGroups={[
{
label: "Security Settings",
description: "Settings for the Security configuration",
fields: [
{
type: "passwordGenerator",
name: "privateKey",
label: "Private Key",
description: "Used to create a shared key with a remote device",
validationText: privateKeyValidationText,
devicePSKBitCount: privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
buttonClick: () =>
clickEvent(
setPrivateKey,
privateKeyBitCount,
setPrivateKeyValidationText,
),
disabledBy: [
{
fieldName: "adminChannelEnabled",
invert: true,
},
],
properties: {
value: privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
<>
<DynamicForm<SecurityValidation>
onSubmit={onSubmit}
submitType="onChange"
defaultValues={{
...config.security,
...{
adminKey: adminKey,
privateKey: privateKey,
publicKey: publicKey,
adminChannelEnabled: config.security?.adminChannelEnabled ?? false,
isManaged: config.security?.isManaged ?? false,
bluetoothLoggingEnabled:
config.security?.bluetoothLoggingEnabled ?? false,
debugLogApiEnabled: config.security?.debugLogApiEnabled ?? false,
serialEnabled: config.security?.serialEnabled ?? false,
},
}}
fieldGroups={[
{
label: "Security Settings",
description: "Settings for the Security configuration",
fields: [
{
type: "passwordGenerator",
name: "privateKey",
label: "Private Key",
description: "Used to create a shared key with a remote device",
bits: [{ text: "256 bit", value: "32", key: "bit256" }],
validationText: privateKeyValidationText,
devicePSKBitCount: privateKeyBitCount,
inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible,
buttonClick: privateKeyClickEvent,
properties: {
value: privateKey,
action: {
icon: privateKeyVisible ? EyeOff : Eye,
onClick: () => setPrivateKeyVisible(!privateKeyVisible),
},
},
},
},
{
type: "text",
name: "publicKey",
label: "Public Key",
disabled: true,
description:
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
},
],
},
{
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),
{
type: "text",
name: "publicKey",
label: "Public Key",
disabled: true,
description:
"Sent out to other nodes on the mesh to allow them to compute a shared secret key",
properties: {
value: publicKey,
},
},
},
],
},
{
label: "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",
},
],
},
]}
/>
],
},
{
label: "Admin Settings",
description: "Settings for Admin",
fields: [
{
type: "toggle",
name: "adminChannelEnabled",
label: "Allow Legacy Admin",
description:
"Allow incoming device control over the insecure legacy admin channel",
},
{
type: "toggle",
name: "isManaged",
label: "Managed",
description:
'If true, device is considered to be "managed" by a mesh administrator via admin messages',
},
{
type: "text",
name: "adminKey",
label: "Admin Key",
description:
"The public key authorized to send admin messages to this node",
validationText: adminKeyValidationText,
inputChange: adminKeyInputChangeEvent,
disabledBy: [
{ fieldName: "adminChannelEnabled", invert: true },
],
properties: {
value: adminKey,
},
},
],
},
{
label: "Logging Settings",
description: "Settings for Logging",
fields: [
{
type: "toggle",
name: "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()}
/>
</>
);
};

View File

@@ -68,7 +68,7 @@ export const ChannelChat = ({
)}
</div>
</div>
<div className="p-3">
<div className="pl-3 pr-3 pt-3 pb-1">
<MessageInput to={to} channel={channel} />
</div>
</div>

View File

@@ -87,7 +87,7 @@ export const Telemetry = (): JSX.Element => {
description: "How often to send Power data over the mesh",
},
{
type: "text",
type: "toggle",
name: "powerScreenEnabled",
label: "Power Screen Enabled",
description: "Enable the Power Telemetry Screen",

View File

@@ -1,5 +1,6 @@
import { cn } from "@app/core/utils/cn.js";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import Footer from "./UI/Footer";
export interface PageLayoutProps {
label: string;
@@ -18,40 +19,43 @@ export const PageLayout = ({
children,
}: PageLayoutProps): JSX.Element => {
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">
<button
type="button"
className="pl-4 transition-all hover:text-accent md:hidden"
>
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action, index) => (
<button
key={action.icon.name}
type="button"
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon />
</button>
))}
<>
<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">
<button
type="button"
className="pl-4 transition-all hover:text-accent md:hidden"
>
<AlignLeftIcon />
</button>
<div className="flex flex-1 items-center justify-between px-4 md:px-0">
<div className="flex w-full items-center">
<span className="w-full text-lg font-medium">{label}</span>
<div className="flex justify-end space-x-4">
{actions?.map((action, index) => (
<button
key={action.icon.name}
type="button"
className="transition-all hover:text-accent"
onClick={action.onClick}
>
<action.icon />
</button>
))}
</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
className={cn(
"flex h-full w-full flex-col overflow-y-auto",
!noPadding && "p-3",
)}
>
{children}
</div>
</div>
</>
);
};

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;

View File

@@ -17,6 +17,7 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
value: string;
variant: "default" | "invalid";
buttonText?: string;
bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
@@ -35,6 +36,11 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
variant,
value,
buttonText,
bits = [
{ text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" },
{ text: "8 bit", value: "1", key: "bit8" },
],
selectChange,
inputChange,
buttonClick,
@@ -44,6 +50,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
},
ref,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
// Invokes onChange event on the input element when the value changes from the parent component
React.useEffect(() => {
if (!inputRef.current) return;
const setValue = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (!setValue) return;
inputRef.current.value = "";
setValue.call(inputRef.current, value);
inputRef.current.dispatchEvent(new Event("input", { bubbles: true }));
}, [value]);
return (
<>
<Input
@@ -54,30 +75,29 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
onChange={inputChange}
action={action}
disabled={disabled}
ref={inputRef}
/>
<Select
value={devicePSKBitCount?.toString()}
onValueChange={(e) => selectChange(e)}
disabled={disabled}
>
<SelectTrigger className="!max-w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="bit256" value="32">
256 bit
</SelectItem>
<SelectItem key="bit128" value="16">
128 bit
</SelectItem>
<SelectItem key="bit8" value="1">
8 bit
</SelectItem>
{bits.map(({ text, value, key }) => (
<SelectItem key={key} value={value}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="success"
onClick={buttonClick}
disabled={disabled}
{...props}
>
{buttonText}

View File

@@ -31,7 +31,7 @@ export interface InputProps
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, variant, prefix, suffix, action, ...props }, ref) => {
({ className, value, variant, prefix, suffix, action, ...props }, ref) => {
return (
<div className="relative w-full">
{prefix && (
@@ -45,6 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className,
inputVariants({ variant }),
)}
value={value}
ref={ref}
{...props}
/>

View File

@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
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,
)}
{...props}

15
src/core/utils/x25519.ts Normal file
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);
}

View File

@@ -21,79 +21,81 @@ export const Dashboard = () => {
const devices = useMemo(() => getDevices(), [getDevices]);
return (
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<H3>Connected Devices</H3>
<Subtle>Manage, connect and disconnect devices</Subtle>
<>
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<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>
<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>
</>
);
};

View File

@@ -67,60 +67,62 @@ export const MessagesPage = (): JSX.Element => {
))}
</SidebarSection>
</Sidebar>
<PageLayout
label={`Messages: ${
chatType === "broadcast" && currentChannel
? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat)
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
: "Loading..."
}`}
actions={
chatType === "direct"
? [
{
icon: WaypointsIcon,
async onClick() {
const targetNode = nodes.get(activeChat)?.num;
if (targetNode === undefined) return;
toast({
title: "Sending Traceroute, please wait...",
});
await connection?.traceRoute(targetNode).then(() =>
<div className="flex flex-col flex-grow">
<PageLayout
label={`Messages: ${
chatType === "broadcast" && currentChannel
? getChannelName(currentChannel)
: chatType === "direct" && nodes.get(activeChat)
? nodes.get(activeChat)?.user?.longName ?? "Unknown"
: "Loading..."
}`}
actions={
chatType === "direct"
? [
{
icon: WaypointsIcon,
async onClick() {
const targetNode = nodes.get(activeChat)?.num;
if (targetNode === undefined) return;
toast({
title: "Traceroute sent.",
}),
);
title: "Sending Traceroute, please wait...",
});
await connection?.traceRoute(targetNode).then(() =>
toast({
title: "Traceroute sent.",
}),
);
},
},
},
]
: []
}
>
{allChannels.map(
(channel) =>
activeChat === channel.index && (
<ChannelChat
key={channel.index}
to="broadcast"
messages={messages.broadcast.get(channel.index)}
channel={channel.index}
/>
),
)}
{filteredNodes.map(
(node) =>
activeChat === node.num && (
<ChannelChat
key={node.num}
to={activeChat}
messages={messages.direct.get(node.num)}
channel={Types.ChannelNumber.Primary}
traceroutes={traceroutes.get(node.num)}
/>
),
)}
</PageLayout>
]
: []
}
>
{allChannels.map(
(channel) =>
activeChat === channel.index && (
<ChannelChat
key={channel.index}
to="broadcast"
messages={messages.broadcast.get(channel.index)}
channel={channel.index}
/>
),
)}
{filteredNodes.map(
(node) =>
activeChat === node.num && (
<ChannelChat
key={node.num}
to={activeChat}
messages={messages.direct.get(node.num)}
channel={Types.ChannelNumber.Primary}
traceroutes={traceroutes.get(node.num)}
/>
),
)}
</PageLayout>
</div>
</>
);
};

View File

@@ -1,3 +1,4 @@
import Footer from "@app/components/UI/Footer";
import { useAppStore } from "@app/core/stores/appStore";
import { Sidebar } from "@components/Sidebar.js";
import { Button } from "@components/UI/Button.js";
@@ -27,73 +28,76 @@ export const NodesPage = (): JSX.Element => {
return (
<>
<Sidebar />
<div className="w-full overflow-y-auto">
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ 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()} />,
<h1 key="header">
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
: `UNK: ${node.num}`)}
</h1>,
<div className="flex flex-col w-full">
<div className="overflow-y-auto h-full">
<Table
headings={[
{ title: "", type: "blank", sortable: false },
{ title: "Name", type: "normal", sortable: true },
{ title: "Model", type: "normal", sortable: true },
{ title: "MAC Address", type: "normal", sortable: true },
{ title: "Last Heard", type: "normal", sortable: true },
{ title: "SNR", type: "normal", sortable: true },
{ 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()} />,
<h1 key="header">
{node.user?.longName ??
(node.user?.macaddr
? `Meshtastic ${base16
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
.toLowerCase()}`
: `UNK: ${node.num}`)}
</h1>,
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? "UNK"}
</Mono>,
<Fragment key="lastHeard">
{node.lastHeard === 0 ? (
<p>Never</p>
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</Fragment>,
<Mono key="snr">
{node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
</Mono>,
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])}
/>
<Mono key="model">
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
</Mono>,
<Mono key="addr">
{base16
.stringify(node.user?.macaddr ?? [])
.match(/.{1,2}/g)
?.join(":") ?? "UNK"}
</Mono>,
<Fragment key="lastHeard">
{node.lastHeard === 0 ? (
<p>Never</p>
) : (
<TimeAgo timestamp={node.lastHeard * 1000} />
)}
</Fragment>,
<Mono key="snr">
{node.snr}db/
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
{(node.snr + 10) * 5}raw
</Mono>,
<Mono key="hops">
{node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0
? "Direct"
: `${node.hopsAway.toString()} ${
node.hopsAway > 1 ? "hops" : "hop"
} away`
: "-"}
{node.viaMqtt === true ? ", via MQTT" : ""}
</Mono>,
<Button
key="remove"
variant="destructive"
onClick={() => {
setNodeNumToBeRemoved(node.num);
setDialogOpen("nodeRemoval", true);
}}
>
<TrashIcon />
Remove
</Button>,
])}
/>
</div>
<Footer />
</div>
</>
);