committed by
GitHub
131 changed files with 8351 additions and 5111 deletions
@ -0,0 +1,2 @@ |
|||||
|
dist/build.tar |
||||
|
dist/output |
||||
@ -0,0 +1,61 @@ |
|||||
|
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: Archive compressed build |
||||
|
uses: actions/upload-artifact@v4 |
||||
|
with: |
||||
|
name: build |
||||
|
path: 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 }}" |
||||
@ -1,4 +1,7 @@ |
|||||
{ |
{ |
||||
"editor.defaultFormatter": "biomejs.biome", |
"editor.defaultFormatter": "biomejs.biome", |
||||
"editor.formatOnSave": true |
"editor.codeActionsOnSave": { |
||||
} |
"quickfix.biome": "explicit" |
||||
|
}, |
||||
|
"editor.formatOnSave": true |
||||
|
} |
||||
|
|||||
@ -1,4 +1,9 @@ |
|||||
FROM registry.access.redhat.com/ubi9/nginx-122:1-45 |
FROM nginx:1.27.2-alpine |
||||
|
|
||||
|
RUN rm -r /usr/share/nginx/html \ |
||||
|
&& mkdir /usr/share/nginx/html |
||||
|
|
||||
|
WORKDIR /usr/share/nginx/html |
||||
|
|
||||
ADD dist . |
ADD dist . |
||||
|
|
||||
|
|||||
@ -5,11 +5,17 @@ |
|||||
"description": "Meshtastic web client", |
"description": "Meshtastic web client", |
||||
"license": "GPL-3.0-only", |
"license": "GPL-3.0-only", |
||||
"scripts": { |
"scripts": { |
||||
"dev": "vite --host", |
"build": "pnpm check && rsbuild build", |
||||
"build": "tsc && vite build", |
"check": "biome check src/", |
||||
"check": "biome check .", |
"check:fix": "pnpm check --write src/", |
||||
"preview": "vite preview", |
"format": "biome format --write src/", |
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)" |
"dev": "rsbuild dev --open", |
||||
|
"preview": "rsbuild preview", |
||||
|
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)", |
||||
|
"postinstall": "npx simple-git-hooks" |
||||
|
}, |
||||
|
"simple-git-hooks": { |
||||
|
"pre-commit": "npm run check:fix && npm run format" |
||||
}, |
}, |
||||
"repository": { |
"repository": { |
||||
"type": "git", |
"type": "git", |
||||
@ -20,64 +26,65 @@ |
|||||
}, |
}, |
||||
"homepage": "https://meshtastic.org", |
"homepage": "https://meshtastic.org", |
||||
"dependencies": { |
"dependencies": { |
||||
"@bufbuild/protobuf": "^1.8.0", |
"@bufbuild/protobuf": "^1.10.0", |
||||
"@emeraldpay/hashicon-react": "^0.5.2", |
"@meshtastic/js": "2.3.7-5", |
||||
"@meshtastic/js": "2.3.4-0", |
"@noble/curves": "^1.8.1", |
||||
"@radix-ui/react-accordion": "^1.1.2", |
"@radix-ui/react-accordion": "^1.2.2", |
||||
"@radix-ui/react-checkbox": "^1.0.4", |
"@radix-ui/react-checkbox": "^1.1.3", |
||||
"@radix-ui/react-dialog": "^1.0.5", |
"@radix-ui/react-dialog": "^1.1.5", |
||||
"@radix-ui/react-dropdown-menu": "^2.0.6", |
"@radix-ui/react-dropdown-menu": "^2.1.5", |
||||
"@radix-ui/react-label": "^2.0.2", |
"@radix-ui/react-label": "^2.1.1", |
||||
"@radix-ui/react-menubar": "^1.0.4", |
"@radix-ui/react-menubar": "^1.1.5", |
||||
"@radix-ui/react-popover": "^1.0.7", |
"@radix-ui/react-popover": "^1.1.5", |
||||
"@radix-ui/react-scroll-area": "^1.0.5", |
"@radix-ui/react-scroll-area": "^1.2.2", |
||||
"@radix-ui/react-select": "^2.0.0", |
"@radix-ui/react-select": "^2.1.5", |
||||
"@radix-ui/react-separator": "^1.0.3", |
"@radix-ui/react-separator": "^1.1.1", |
||||
"@radix-ui/react-switch": "^1.0.3", |
"@radix-ui/react-switch": "^1.1.2", |
||||
"@radix-ui/react-tabs": "^1.0.4", |
"@radix-ui/react-tabs": "^1.1.2", |
||||
"@radix-ui/react-toast": "^1.1.5", |
"@radix-ui/react-toast": "^1.2.5", |
||||
"@radix-ui/react-tooltip": "^1.0.7", |
"@radix-ui/react-tooltip": "^1.1.7", |
||||
"@turf/turf": "^6.5.0", |
"@turf/turf": "^7.2.0", |
||||
"base64-js": "^1.5.1", |
"base64-js": "^1.5.1", |
||||
"class-transformer": "^0.5.1", |
|
||||
"class-validator": "^0.14.1", |
"class-validator": "^0.14.1", |
||||
"class-variance-authority": "^0.7.0", |
"class-variance-authority": "^0.7.1", |
||||
"clsx": "^2.1.0", |
"clsx": "^2.1.1", |
||||
"cmdk": "^1.0.0", |
"cmdk": "^1.0.4", |
||||
"immer": "^10.0.4", |
"crypto-random-string": "^5.0.0", |
||||
"lucide-react": "^0.363.0", |
"immer": "^10.1.1", |
||||
"mapbox-gl": "npm:empty-npm-package@^1.0.0", |
"js-cookie": "^3.0.5", |
||||
|
"lucide-react": "^0.474.0", |
||||
|
"mapbox-gl": "^3.9.4", |
||||
"maplibre-gl": "4.1.2", |
"maplibre-gl": "4.1.2", |
||||
"react": "^18.2.0", |
"react": "^19.0.0", |
||||
"react-dom": "^18.2.0", |
"react-dom": "^19.0.0", |
||||
"react-hook-form": "^7.51.2", |
"react-hook-form": "^7.54.2", |
||||
"react-map-gl": "7.1.7", |
"react-map-gl": "7.1.9", |
||||
"react-qrcode-logo": "^2.9.0", |
"react-qrcode-logo": "^3.0.0", |
||||
"rfc4648": "^1.5.3", |
"rfc4648": "^1.5.4", |
||||
"tailwind-merge": "^2.2.2", |
|
||||
"tailwindcss-animate": "^1.0.7", |
|
||||
"timeago-react": "^3.0.6", |
"timeago-react": "^3.0.6", |
||||
"zustand": "4.5.2" |
"vite-plugin-node-polyfills": "^0.23.0", |
||||
|
"zustand": "5.0.3" |
||||
}, |
}, |
||||
"devDependencies": { |
"devDependencies": { |
||||
"@biomejs/biome": "^1.6.3", |
"@biomejs/biome": "^1.9.4", |
||||
"@buf/meshtastic_protobufs.bufbuild_es": "1.8.0-20240325205556-b11811405eea.2", |
"@rsbuild/core": "^1.2.3", |
||||
"@types/chrome": "^0.0.263", |
"@rsbuild/plugin-react": "^1.1.0", |
||||
"@types/node": "^20.11.30", |
"@types/chrome": "^0.0.299", |
||||
"@types/react": "^18.2.73", |
"@types/js-cookie": "^3.0.6", |
||||
"@types/react-dom": "^18.2.23", |
"@types/node": "^22.12.0", |
||||
"@types/w3c-web-serial": "^1.0.6", |
"@types/react": "^19.0.8", |
||||
|
"@types/react-dom": "^19.0.3", |
||||
|
"@types/w3c-web-serial": "^1.0.7", |
||||
"@types/web-bluetooth": "^0.0.20", |
"@types/web-bluetooth": "^0.0.20", |
||||
"@vitejs/plugin-react": "^4.2.1", |
"autoprefixer": "^10.4.20", |
||||
"autoprefixer": "^10.4.19", |
"gzipper": "^8.2.0", |
||||
"gzipper": "^7.2.0", |
"postcss": "^8.5.1", |
||||
"postcss": "^8.4.38", |
"simple-git-hooks": "^2.11.1", |
||||
"rollup-plugin-visualizer": "^5.12.0", |
"tailwind-merge": "^2.6.0", |
||||
"tailwindcss": "^3.4.3", |
"tailwindcss": "^3.4.17", |
||||
"tar": "^6.2.1", |
"tailwindcss-animate": "^1.0.7", |
||||
"tslib": "^2.6.2", |
"tar": "^7.4.3", |
||||
"typescript": "^5.4.3", |
"typescript": "^5.7.3" |
||||
"vite": "^5.2.6", |
}, |
||||
"vite-plugin-environment": "^1.1.3" |
"packageManager": "[email protected]" |
||||
} |
|
||||
} |
} |
||||
|
|||||
File diff suppressed because it is too large
@ -1,6 +1,6 @@ |
|||||
module.exports = { |
module.exports = { |
||||
plugins: { |
plugins: { |
||||
tailwindcss: {}, |
tailwindcss: {}, |
||||
autoprefixer: {} |
autoprefixer: {}, |
||||
} |
}, |
||||
}; |
}; |
||||
|
|||||
@ -0,0 +1,30 @@ |
|||||
|
import { execSync } from "node:child_process"; |
||||
|
import { defineConfig } from "@rsbuild/core"; |
||||
|
import { pluginReact } from "@rsbuild/plugin-react"; |
||||
|
|
||||
|
let hash = ""; |
||||
|
|
||||
|
try { |
||||
|
hash = execSync("git rev-parse --short HEAD").toString().trim(); |
||||
|
} catch (error) { |
||||
|
hash = "DEV"; |
||||
|
} |
||||
|
|
||||
|
export default defineConfig({ |
||||
|
plugins: [pluginReact()], |
||||
|
source: { |
||||
|
define: { |
||||
|
"process.env.COMMIT_HASH": JSON.stringify(hash), |
||||
|
}, |
||||
|
alias: { |
||||
|
"@app": "./src", |
||||
|
"@pages": "./src/pages", |
||||
|
"@components": "./src/components", |
||||
|
"@core": "./src/core", |
||||
|
"@layouts": "./src/layouts", |
||||
|
}, |
||||
|
}, |
||||
|
html: { |
||||
|
title: "Meshtastic Web", |
||||
|
}, |
||||
|
}); |
||||
@ -0,0 +1,134 @@ |
|||||
|
import { useDevice } from "@app/core/stores/deviceStore"; |
||||
|
import { Button } from "@components/UI/Button"; |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogDescription, |
||||
|
DialogFooter, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
} from "@components/UI/Dialog.tsx"; |
||||
|
import { fromByteArray } from "base64-js"; |
||||
|
import { DownloadIcon, PrinterIcon } from "lucide-react"; |
||||
|
import React from "react"; |
||||
|
|
||||
|
export interface PkiBackupDialogProps { |
||||
|
open: boolean; |
||||
|
onOpenChange: (open: boolean) => void; |
||||
|
} |
||||
|
|
||||
|
export const PkiBackupDialog = ({ |
||||
|
open, |
||||
|
onOpenChange, |
||||
|
}: PkiBackupDialogProps) => { |
||||
|
const { config, setDialogOpen } = useDevice(); |
||||
|
const privateKey = config.security?.privateKey; |
||||
|
const publicKey = config.security?.publicKey; |
||||
|
|
||||
|
const decodeKeyData = React.useCallback( |
||||
|
(key: Uint8Array<ArrayBufferLike>) => { |
||||
|
if (!key) return ""; |
||||
|
return fromByteArray(key ?? new Uint8Array(0)); |
||||
|
}, |
||||
|
[], |
||||
|
); |
||||
|
|
||||
|
const closeDialog = React.useCallback(() => { |
||||
|
setDialogOpen("pkiBackup", false); |
||||
|
}, [setDialogOpen]); |
||||
|
|
||||
|
const renderPrintWindow = React.useCallback(() => { |
||||
|
if (!privateKey || !publicKey) return; |
||||
|
|
||||
|
const printWindow = window.open("", "_blank"); |
||||
|
if (printWindow) { |
||||
|
printWindow.document.write(` |
||||
|
<html> |
||||
|
<head> |
||||
|
<title>=== MESHTASTIC KEYS ===</title> |
||||
|
<style> |
||||
|
body { font-family: Arial, sans-serif; padding: 20px; } |
||||
|
h1 { font-size: 18px; } |
||||
|
p { font-size: 14px; word-break: break-all; } |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<h1>=== MESHTASTIC KEYS ===</h1> |
||||
|
<br> |
||||
|
<h2>Public Key:</h2> |
||||
|
<p>${decodeKeyData(publicKey)}</p> |
||||
|
<h2>Private Key:</h2> |
||||
|
<p>${decodeKeyData(privateKey)}</p> |
||||
|
<br> |
||||
|
<p>=== END OF KEYS ===</p> |
||||
|
</body> |
||||
|
</html> |
||||
|
`);
|
||||
|
printWindow.document.close(); |
||||
|
printWindow.print(); |
||||
|
closeDialog(); |
||||
|
} |
||||
|
}, [decodeKeyData, privateKey, publicKey, closeDialog]); |
||||
|
|
||||
|
const createDownloadKeyFile = React.useCallback(() => { |
||||
|
if (!privateKey || !publicKey) return; |
||||
|
|
||||
|
const decodedPrivateKey = decodeKeyData(privateKey); |
||||
|
const decodedPublicKey = decodeKeyData(publicKey); |
||||
|
|
||||
|
const formattedContent = [ |
||||
|
"=== MESHTASTIC KEYS ===\n\n", |
||||
|
"Private Key:\n", |
||||
|
decodedPrivateKey, |
||||
|
"\n\nPublic Key:\n", |
||||
|
decodedPublicKey, |
||||
|
"\n\n=== END OF KEYS ===", |
||||
|
].join(""); |
||||
|
|
||||
|
const blob = new Blob([formattedContent], { type: "text/plain" }); |
||||
|
const url = URL.createObjectURL(blob); |
||||
|
|
||||
|
const link = document.createElement("a"); |
||||
|
link.href = url; |
||||
|
link.download = "meshtastic_keys.txt"; |
||||
|
link.style.display = "none"; |
||||
|
document.body.appendChild(link); |
||||
|
link.click(); |
||||
|
document.body.removeChild(link); |
||||
|
closeDialog(); |
||||
|
URL.revokeObjectURL(url); |
||||
|
}, [decodeKeyData, privateKey, publicKey, closeDialog]); |
||||
|
|
||||
|
return ( |
||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||
|
<DialogContent> |
||||
|
<DialogHeader> |
||||
|
<DialogTitle>Backup Keys</DialogTitle> |
||||
|
<DialogDescription> |
||||
|
Its important to backup your public and private keys and store your |
||||
|
backup securely! |
||||
|
</DialogDescription> |
||||
|
<DialogDescription> |
||||
|
<span className="font-bold break-before-auto"> |
||||
|
If you lose your keys, you will need to reset your device. |
||||
|
</span> |
||||
|
</DialogDescription> |
||||
|
</DialogHeader> |
||||
|
<DialogFooter className="mt-6"> |
||||
|
<Button |
||||
|
variant={"default"} |
||||
|
onClick={() => createDownloadKeyFile()} |
||||
|
className="" |
||||
|
> |
||||
|
<DownloadIcon size={20} className="mr-2" /> |
||||
|
Download |
||||
|
</Button> |
||||
|
<Button variant={"default"} onClick={() => renderPrintWindow()}> |
||||
|
<PrinterIcon size={20} className="mr-2" /> |
||||
|
Print |
||||
|
</Button> |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,39 @@ |
|||||
|
import { Button } from "@components/UI/Button.tsx"; |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogDescription, |
||||
|
DialogFooter, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
} from "@components/UI/Dialog.tsx"; |
||||
|
|
||||
|
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> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,66 @@ |
|||||
|
import type { |
||||
|
BaseFormBuilderProps, |
||||
|
GenericFormElementProps, |
||||
|
} from "@components/Form/DynamicForm.tsx"; |
||||
|
import type { ButtonVariant } from "@components/UI/Button"; |
||||
|
import { Generator } from "@components/UI/Generator.tsx"; |
||||
|
import { Eye, EyeOff } from "lucide-react"; |
||||
|
import type { ChangeEventHandler, MouseEventHandler } from "react"; |
||||
|
import { useState } from "react"; |
||||
|
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; |
||||
|
actionButtons: { |
||||
|
text: string; |
||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>; |
||||
|
variant: ButtonVariant; |
||||
|
className?: string; |
||||
|
}[]; |
||||
|
} |
||||
|
|
||||
|
export function PasswordGenerator<T extends FieldValues>({ |
||||
|
control, |
||||
|
field, |
||||
|
disabled, |
||||
|
}: GenericFormElementProps<T, PasswordGeneratorProps<T>>) { |
||||
|
const [passwordShown, setPasswordShown] = useState(false); |
||||
|
const togglePasswordVisiblity = () => { |
||||
|
setPasswordShown(!passwordShown); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Controller |
||||
|
name={field.name} |
||||
|
control={control} |
||||
|
render={({ field: { value, ...rest } }) => ( |
||||
|
<Generator |
||||
|
type={field.hide && !passwordShown ? "password" : "text"} |
||||
|
action={ |
||||
|
field.hide |
||||
|
? { |
||||
|
icon: passwordShown ? EyeOff : Eye, |
||||
|
onClick: togglePasswordVisiblity, |
||||
|
} |
||||
|
: undefined |
||||
|
} |
||||
|
devicePSKBitCount={field.devicePSKBitCount} |
||||
|
bits={field.bits} |
||||
|
inputChange={field.inputChange} |
||||
|
selectChange={field.selectChange} |
||||
|
value={value} |
||||
|
variant={field.validationText ? "invalid" : "default"} |
||||
|
actionButtons={field.actionButtons} |
||||
|
{...field.properties} |
||||
|
{...rest} |
||||
|
disabled={disabled} |
||||
|
/> |
||||
|
)} |
||||
|
/> |
||||
|
); |
||||
|
} |
||||
@ -1,30 +1,37 @@ |
|||||
import { Label } from "@components/UI/Label.js"; |
import { Label } from "@components/UI/Label.tsx"; |
||||
|
|
||||
export interface FieldWrapperProps { |
export interface FieldWrapperProps { |
||||
label: string; |
label: string; |
||||
description?: string; |
description?: string; |
||||
disabled?: boolean; |
disabled?: boolean; |
||||
children?: React.ReactNode; |
children?: React.ReactNode; |
||||
|
valid?: boolean; |
||||
|
validationText?: string; |
||||
} |
} |
||||
|
|
||||
export const FieldWrapper = ({ |
export const FieldWrapper = ({ |
||||
label, |
label, |
||||
description, |
description, |
||||
children, |
children, |
||||
}: FieldWrapperProps): JSX.Element => ( |
valid, |
||||
|
validationText, |
||||
|
}: FieldWrapperProps) => ( |
||||
<div className="pt-6 sm:pt-5"> |
<div className="pt-6 sm:pt-5"> |
||||
<div role="group" aria-labelledby="label-notifications"> |
<fieldset aria-labelledby="label-notifications"> |
||||
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4"> |
<div className="sm:grid sm:grid-cols-3 sm:items-baseline sm:gap-4"> |
||||
<Label>{label}</Label> |
<Label>{label}</Label> |
||||
<div className="sm:col-span-2"> |
<div className="sm:col-span-2"> |
||||
<div className="max-w-lg"> |
<div className="max-w-lg"> |
||||
<p className="text-sm text-gray-500">{description}</p> |
<p className="text-sm text-gray-500">{description}</p> |
||||
|
<p hidden={valid ?? true} className="text-sm text-red-500"> |
||||
|
{validationText} |
||||
|
</p> |
||||
<div className="mt-4 space-y-4"> |
<div className="mt-4 space-y-4"> |
||||
<div className="flex items-center">{children}</div> |
<div className="flex items-center">{children}</div> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</div> |
</div> |
||||
</div> |
</fieldset> |
||||
</div> |
</div> |
||||
); |
); |
||||
|
|||||
@ -0,0 +1,19 @@ |
|||||
|
import { useBackupReminder } from "@app/core/hooks/useKeyBackupReminder"; |
||||
|
import { useDevice } from "@app/core/stores/deviceStore"; |
||||
|
|
||||
|
export const KeyBackupReminder = (): JSX.Element => { |
||||
|
const { setDialogOpen } = useDevice(); |
||||
|
|
||||
|
useBackupReminder({ |
||||
|
reminderInDays: 7, |
||||
|
message: |
||||
|
"We recommend backing up your key data regularly. Would you like to back up now?", |
||||
|
onAccept: () => setDialogOpen("pkiBackup", true), |
||||
|
enabled: true, |
||||
|
cookieOptions: { |
||||
|
secure: true, |
||||
|
sameSite: "strict", |
||||
|
}, |
||||
|
}); |
||||
|
return <></>; |
||||
|
}; |
||||
@ -0,0 +1,254 @@ |
|||||
|
import { PkiRegenerateDialog } from "@app/components/Dialog/PkiRegenerateDialog"; |
||||
|
import { DynamicForm } from "@app/components/Form/DynamicForm.tsx"; |
||||
|
import { |
||||
|
getX25519PrivateKey, |
||||
|
getX25519PublicKey, |
||||
|
} from "@app/core/utils/x25519"; |
||||
|
import type { SecurityValidation } from "@app/validation/config/security.tsx"; |
||||
|
import { useDevice } from "@core/stores/deviceStore.ts"; |
||||
|
import { Protobuf } from "@meshtastic/js"; |
||||
|
import { fromByteArray, toByteArray } from "base64-js"; |
||||
|
import { Eye, EyeOff } from "lucide-react"; |
||||
|
import { useState } from "react"; |
||||
|
|
||||
|
export const Security = (): JSX.Element => { |
||||
|
const { config, nodes, hardware, setWorkingConfig, setDialogOpen } = |
||||
|
useDevice(); |
||||
|
|
||||
|
const [privateKey, setPrivateKey] = useState<string>( |
||||
|
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), |
||||
|
); |
||||
|
const [privateKeyVisible, setPrivateKeyVisible] = useState<boolean>(false); |
||||
|
const [privateKeyBitCount, setPrivateKeyBitCount] = useState<number>( |
||||
|
config.security?.privateKey.length ?? 32, |
||||
|
); |
||||
|
const [privateKeyValidationText, setPrivateKeyValidationText] = |
||||
|
useState<string>(); |
||||
|
const [publicKey, setPublicKey] = useState<string>( |
||||
|
fromByteArray(config.security?.publicKey ?? new Uint8Array(0)), |
||||
|
); |
||||
|
const [adminKey, setAdminKey] = useState<string>( |
||||
|
fromByteArray(config.security?.adminKey[0] ?? new Uint8Array(0)), |
||||
|
); |
||||
|
const [adminKeyValidationText, setAdminKeyValidationText] = |
||||
|
useState<string>(); |
||||
|
const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = |
||||
|
useState<boolean>(false); |
||||
|
|
||||
|
const onSubmit = (data: SecurityValidation) => { |
||||
|
if (privateKeyValidationText || adminKeyValidationText) return; |
||||
|
|
||||
|
setWorkingConfig( |
||||
|
new Protobuf.Config.Config({ |
||||
|
payloadVariant: { |
||||
|
case: "security", |
||||
|
value: { |
||||
|
...data, |
||||
|
adminKey: [toByteArray(adminKey)], |
||||
|
privateKey: toByteArray(privateKey), |
||||
|
publicKey: toByteArray(publicKey), |
||||
|
}, |
||||
|
}, |
||||
|
}), |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const validateKey = ( |
||||
|
input: string, |
||||
|
count: number, |
||||
|
setValidationText: ( |
||||
|
value: React.SetStateAction<string | undefined>, |
||||
|
) => void, |
||||
|
) => { |
||||
|
try { |
||||
|
if (input.length % 4 !== 0 || toByteArray(input).length !== count) { |
||||
|
setValidationText(`Please enter a valid ${count * 8} bit PSK.`); |
||||
|
} else { |
||||
|
setValidationText(undefined); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
console.error(e); |
||||
|
setValidationText(`Please enter a valid ${count * 8} bit PSK.`); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const privateKeyClickEvent = () => { |
||||
|
setPrivateKeyDialogOpen(true); |
||||
|
}; |
||||
|
|
||||
|
const pkiBackupClickEvent = () => { |
||||
|
setDialogOpen("pkiBackup", true); |
||||
|
}; |
||||
|
|
||||
|
const pkiRegenerate = () => { |
||||
|
const privateKey = getX25519PrivateKey(); |
||||
|
const publicKey = getX25519PublicKey(privateKey); |
||||
|
|
||||
|
setPrivateKey(fromByteArray(privateKey)); |
||||
|
setPublicKey(fromByteArray(publicKey)); |
||||
|
validateKey( |
||||
|
fromByteArray(privateKey), |
||||
|
privateKeyBitCount, |
||||
|
setPrivateKeyValidationText, |
||||
|
); |
||||
|
|
||||
|
setPrivateKeyDialogOpen(false); |
||||
|
}; |
||||
|
|
||||
|
const privateKeyInputChangeEvent = ( |
||||
|
e: React.ChangeEvent<HTMLInputElement>, |
||||
|
) => { |
||||
|
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); |
||||
|
validateKey(psk, privateKeyBitCount, setAdminKeyValidationText); |
||||
|
}; |
||||
|
|
||||
|
const privateKeySelectChangeEvent = (e: string) => { |
||||
|
const count = Number.parseInt(e); |
||||
|
setPrivateKeyBitCount(count); |
||||
|
validateKey(privateKey, count, setPrivateKeyValidationText); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<> |
||||
|
<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, |
||||
|
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, |
||||
|
actionButtons: [ |
||||
|
{ |
||||
|
text: "Generate", |
||||
|
onClick: privateKeyClickEvent, |
||||
|
variant: "success", |
||||
|
}, |
||||
|
{ |
||||
|
text: "Backup Key", |
||||
|
onClick: pkiBackupClickEvent, |
||||
|
variant: "subtle", |
||||
|
}, |
||||
|
], |
||||
|
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", |
||||
|
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 configuration options are only able to be changed remotely by a Remote Admin node via admin messages. Do not enable this option unless a suitable Remote Admin node has been setup, and the public key stored in the field below.", |
||||
|
}, |
||||
|
{ |
||||
|
type: "text", |
||||
|
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: "debugLogApiEnabled", |
||||
|
label: "Enable Debug Log API", |
||||
|
description: |
||||
|
"Output live debug logging over serial, view and export position-redacted device logs over Bluetooth", |
||||
|
}, |
||||
|
{ |
||||
|
type: "toggle", |
||||
|
name: "serialEnabled", |
||||
|
label: "Serial Output Enabled", |
||||
|
description: "Serial Console over the Stream API", |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
]} |
||||
|
/> |
||||
|
<PkiRegenerateDialog |
||||
|
open={privateKeyDialogOpen} |
||||
|
onOpenChange={() => setPrivateKeyDialogOpen(false)} |
||||
|
onSubmit={() => pkiRegenerate()} |
||||
|
/> |
||||
|
</> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,171 @@ |
|||||
|
import { Separator } from "@app/components/UI/Seperator"; |
||||
|
import { H5 } from "@app/components/UI/Typography/H5.tsx"; |
||||
|
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; |
||||
|
import { Avatar } from "@components/UI/Avatar"; |
||||
|
import { Mono } from "@components/generic/Mono.tsx"; |
||||
|
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx"; |
||||
|
import { Protobuf } from "@meshtastic/js"; |
||||
|
import type { Protobuf as ProtobufType } from "@meshtastic/js"; |
||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
||||
|
import { |
||||
|
BatteryChargingIcon, |
||||
|
BatteryFullIcon, |
||||
|
BatteryLowIcon, |
||||
|
BatteryMediumIcon, |
||||
|
Dot, |
||||
|
LockIcon, |
||||
|
LockOpenIcon, |
||||
|
MountainSnow, |
||||
|
Star, |
||||
|
} from "lucide-react"; |
||||
|
|
||||
|
export interface NodeDetailProps { |
||||
|
node: ProtobufType.Mesh.NodeInfo; |
||||
|
} |
||||
|
|
||||
|
export const NodeDetail = ({ node }: NodeDetailProps) => { |
||||
|
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`; |
||||
|
const hardwareType = Protobuf.Mesh.HardwareModel[ |
||||
|
node.user?.hwModel ?? 0 |
||||
|
].replaceAll("_", " "); |
||||
|
|
||||
|
return ( |
||||
|
<div className="dark:text-black"> |
||||
|
<div className="flex gap-2"> |
||||
|
<div className="flex flex-col items-center gap-2 min-w-6 pt-1"> |
||||
|
<Avatar text={node.user?.shortName} /> |
||||
|
|
||||
|
<div> |
||||
|
{node.user?.publicKey && node.user?.publicKey.length > 0 ? ( |
||||
|
<LockIcon |
||||
|
className="text-green-600" |
||||
|
size={12} |
||||
|
strokeWidth={3} |
||||
|
aria-label="Public Key Enabled" |
||||
|
/> |
||||
|
) : ( |
||||
|
<LockOpenIcon |
||||
|
className="text-yellow-500" |
||||
|
size={12} |
||||
|
strokeWidth={3} |
||||
|
aria-label="No Public Key" |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<Star |
||||
|
fill={node.isFavorite ? "black" : "none"} |
||||
|
size={15} |
||||
|
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div> |
||||
|
<H5>{name}</H5> |
||||
|
|
||||
|
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>} |
||||
|
|
||||
|
{!!node.deviceMetrics?.batteryLevel && ( |
||||
|
<div |
||||
|
className="flex items-center gap-1" |
||||
|
title={`${ |
||||
|
node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown" |
||||
|
} volts`}
|
||||
|
> |
||||
|
{node.deviceMetrics?.batteryLevel > 100 ? ( |
||||
|
<BatteryChargingIcon size={22} /> |
||||
|
) : node.deviceMetrics?.batteryLevel > 80 ? ( |
||||
|
<BatteryFullIcon size={22} /> |
||||
|
) : node.deviceMetrics?.batteryLevel > 20 ? ( |
||||
|
<BatteryMediumIcon size={22} /> |
||||
|
) : ( |
||||
|
<BatteryLowIcon size={22} /> |
||||
|
)} |
||||
|
<Subtle aria-label="Battery"> |
||||
|
{node.deviceMetrics?.batteryLevel > 100 |
||||
|
? "Charging" |
||||
|
: `${node.deviceMetrics?.batteryLevel}%`} |
||||
|
</Subtle> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
<div className="flex gap-2 items-center"> |
||||
|
{node.user?.shortName && <div>"{node.user?.shortName}"</div>} |
||||
|
{node.user?.id && <div>{node.user?.id}</div>} |
||||
|
</div> |
||||
|
|
||||
|
<div |
||||
|
className="flex gap-1" |
||||
|
title={new Date(node.lastHeard * 1000).toLocaleString( |
||||
|
navigator.language, |
||||
|
)} |
||||
|
> |
||||
|
<div> |
||||
|
{node.lastHeard > 0 && ( |
||||
|
<div> |
||||
|
Heard <TimeAgo timestamp={node.lastHeard * 1000} /> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
{node.viaMqtt && ( |
||||
|
<div style={{ color: "#660066" }} className="font-medium"> |
||||
|
MQTT |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<Separator className="my-1" /> |
||||
|
|
||||
|
<div className="flex mt-2 text-sm"> |
||||
|
<div className="flex items-center flex-grow"> |
||||
|
<div className="border-2 border-black rounded px-0.5 mr-1"> |
||||
|
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway} |
||||
|
</div> |
||||
|
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div> |
||||
|
</div> |
||||
|
{node.position?.altitude && ( |
||||
|
<div className="flex items-center flex-grow"> |
||||
|
<MountainSnow |
||||
|
size={15} |
||||
|
className="ml-2 mr-1" |
||||
|
aria-label="Elevation" |
||||
|
/> |
||||
|
<div>{node.position?.altitude} ft</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex mt-2"> |
||||
|
{!!node.deviceMetrics?.channelUtilization && ( |
||||
|
<div className="flex-grow"> |
||||
|
<div>Channel Util</div> |
||||
|
<Mono> |
||||
|
{node.deviceMetrics?.channelUtilization.toPrecision(3)}% |
||||
|
</Mono> |
||||
|
</div> |
||||
|
)} |
||||
|
{!!node.deviceMetrics?.airUtilTx && ( |
||||
|
<div className="flex-grow"> |
||||
|
<div>Airtime Util</div> |
||||
|
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{node.snr !== 0 && ( |
||||
|
<div className="mt-2"> |
||||
|
<div>SNR</div> |
||||
|
<Mono className="flex items-center text-xs"> |
||||
|
{node.snr}db |
||||
|
<Dot /> |
||||
|
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}% |
||||
|
<Dot /> |
||||
|
{(node.snr + 10) * 5}raw |
||||
|
</Mono> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,36 @@ |
|||||
|
import { useDevice } from "@app/core/stores/deviceStore.ts"; |
||||
|
import type { Protobuf } from "@meshtastic/js"; |
||||
|
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
||||
|
|
||||
|
export interface TraceRouteProps { |
||||
|
from?: Protobuf.Mesh.NodeInfo; |
||||
|
to?: Protobuf.Mesh.NodeInfo; |
||||
|
route: Array<number>; |
||||
|
} |
||||
|
|
||||
|
export const TraceRoute = ({ |
||||
|
from, |
||||
|
to, |
||||
|
route, |
||||
|
}: TraceRouteProps): JSX.Element => { |
||||
|
const { nodes } = useDevice(); |
||||
|
|
||||
|
return route.length === 0 ? ( |
||||
|
<div className="ml-5 flex"> |
||||
|
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> |
||||
|
{to?.user?.longName}↔{from?.user?.longName} |
||||
|
</span> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="ml-5 flex"> |
||||
|
<span className="ml-4 border-l-2 border-l-backgroundPrimary pl-2 text-textPrimary"> |
||||
|
{to?.user?.longName}↔ |
||||
|
{route.map((hop) => { |
||||
|
const node = nodes.get(hop); |
||||
|
return `${node?.user?.longName ?? (node?.num ? numberToHexUnpadded(node.num) : "Unknown")}↔`; |
||||
|
})} |
||||
|
{from?.user?.longName} |
||||
|
</span> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,94 @@ |
|||||
|
import { cn } from "@app/core/utils/cn"; |
||||
|
import type React from "react"; |
||||
|
|
||||
|
type RGBColor = { |
||||
|
r: number; |
||||
|
g: number; |
||||
|
b: number; |
||||
|
a: number; |
||||
|
}; |
||||
|
|
||||
|
interface AvatarProps { |
||||
|
text: string; |
||||
|
size?: "sm" | "lg"; |
||||
|
className?: string; |
||||
|
} |
||||
|
|
||||
|
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
|
||||
|
class ColorUtils { |
||||
|
static hexToRgb(hex: number): RGBColor { |
||||
|
return { |
||||
|
r: (hex & 0xff0000) >> 16, |
||||
|
g: (hex & 0x00ff00) >> 8, |
||||
|
b: hex & 0x0000ff, |
||||
|
a: 255, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
static rgbToHex(color: RGBColor): number { |
||||
|
return ( |
||||
|
(Math.round(color.a) << 24) | |
||||
|
(Math.round(color.r) << 16) | |
||||
|
(Math.round(color.g) << 8) | |
||||
|
Math.round(color.b) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
static isLight(color: RGBColor): boolean { |
||||
|
const brightness = (color.r * 299 + color.g * 587 + color.b * 114) / 1000; |
||||
|
return brightness > 127.5; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const Avatar: React.FC<AvatarProps> = ({ |
||||
|
text, |
||||
|
size = "sm", |
||||
|
className, |
||||
|
}) => { |
||||
|
const sizes = { |
||||
|
sm: "size-11 text-xs", |
||||
|
lg: "size-16 text-lg", |
||||
|
}; |
||||
|
|
||||
|
// Pick a color based on the text provided to function
|
||||
|
const getColorFromText = (text: string): RGBColor => { |
||||
|
let hash = 0; |
||||
|
for (let i = 0; i < text.length; i++) { |
||||
|
hash = text.charCodeAt(i) + ((hash << 5) - hash); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
r: (hash & 0xff0000) >> 16, |
||||
|
g: (hash & 0x00ff00) >> 8, |
||||
|
b: hash & 0x0000ff, |
||||
|
a: 255, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
const bgColor = getColorFromText(text ?? "UNK"); |
||||
|
const isLight = ColorUtils.isLight(bgColor); |
||||
|
const textColor = isLight ? "#000000" : "#FFFFFF"; |
||||
|
const initials = text?.toUpperCase().slice(0, 4) ?? "UNK"; |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
className={cn( |
||||
|
` |
||||
|
rounded-full |
||||
|
flex |
||||
|
items-center |
||||
|
justify-center |
||||
|
size-11 |
||||
|
font-semibold`,
|
||||
|
sizes[size], |
||||
|
className, |
||||
|
)} |
||||
|
style={{ |
||||
|
backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`, |
||||
|
color: textColor, |
||||
|
}} |
||||
|
> |
||||
|
<p className="p-1">{initials}</p> |
||||
|
</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; |
||||
@ -0,0 +1,121 @@ |
|||||
|
import * as React from "react"; |
||||
|
|
||||
|
import { Button, type ButtonVariant } from "@components/UI/Button.tsx"; |
||||
|
import { Input } from "@components/UI/Input.tsx"; |
||||
|
import { |
||||
|
Select, |
||||
|
SelectContent, |
||||
|
SelectItem, |
||||
|
SelectTrigger, |
||||
|
SelectValue, |
||||
|
} from "@components/UI/Select.tsx"; |
||||
|
import type { LucideIcon } from "lucide-react"; |
||||
|
|
||||
|
export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> { |
||||
|
type: "text" | "password"; |
||||
|
devicePSKBitCount?: number; |
||||
|
value: string; |
||||
|
variant: "default" | "invalid"; |
||||
|
actionButtons: { |
||||
|
text: string; |
||||
|
onClick: React.MouseEventHandler<HTMLButtonElement>; |
||||
|
variant: ButtonVariant; |
||||
|
className?: string; |
||||
|
}[]; |
||||
|
bits?: { text: string; value: string; key: string }[]; |
||||
|
selectChange: (event: string) => void; |
||||
|
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void; |
||||
|
action?: { |
||||
|
icon: LucideIcon; |
||||
|
onClick: () => void; |
||||
|
}; |
||||
|
disabled?: boolean; |
||||
|
} |
||||
|
|
||||
|
const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>( |
||||
|
( |
||||
|
{ |
||||
|
type, |
||||
|
devicePSKBitCount, |
||||
|
variant, |
||||
|
value, |
||||
|
actionButtons, |
||||
|
bits = [ |
||||
|
{ text: "256 bit", value: "32", key: "bit256" }, |
||||
|
{ text: "128 bit", value: "16", key: "bit128" }, |
||||
|
{ text: "8 bit", value: "1", key: "bit8" }, |
||||
|
{ text: "Empty", value: "0", key: "empty" }, |
||||
|
], |
||||
|
selectChange, |
||||
|
inputChange, |
||||
|
action, |
||||
|
disabled, |
||||
|
...props |
||||
|
}, |
||||
|
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 |
||||
|
type={type} |
||||
|
id="pskInput" |
||||
|
variant={variant} |
||||
|
value={value} |
||||
|
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> |
||||
|
{bits.map(({ text, value, key }) => ( |
||||
|
<SelectItem key={key} value={value}> |
||||
|
{text} |
||||
|
</SelectItem> |
||||
|
))} |
||||
|
</SelectContent> |
||||
|
</Select> |
||||
|
<div className="flex ml-4 space-x-4"> |
||||
|
{actionButtons?.map(({ text, onClick, variant, className }) => ( |
||||
|
<Button |
||||
|
key={text} |
||||
|
type="button" |
||||
|
onClick={onClick} |
||||
|
disabled={disabled} |
||||
|
variant={variant} |
||||
|
className={className} |
||||
|
{...props} |
||||
|
> |
||||
|
{text} |
||||
|
</Button> |
||||
|
))} |
||||
|
</div> |
||||
|
</> |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
Generator.displayName = "Button"; |
||||
|
|
||||
|
export { Generator }; |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { cn } from "@app/core/utils/cn.ts"; |
||||
|
|
||||
|
export interface H5Props { |
||||
|
className?: string; |
||||
|
children: React.ReactNode; |
||||
|
} |
||||
|
|
||||
|
export const H5 = ({ className, children }: H5Props): JSX.Element => ( |
||||
|
<h5 |
||||
|
className={cn("scroll-m-20 text-lg font-medium tracking-tight", className)} |
||||
|
> |
||||
|
{children} |
||||
|
</h5> |
||||
|
); |
||||
@ -0,0 +1,33 @@ |
|||||
|
import { useMemo } from "react"; |
||||
|
|
||||
|
export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context"; |
||||
|
|
||||
|
interface BrowserSupport { |
||||
|
supported: BrowserFeature[]; |
||||
|
unsupported: BrowserFeature[]; |
||||
|
} |
||||
|
|
||||
|
export function useBrowserFeatureDetection(): BrowserSupport { |
||||
|
const support = useMemo(() => { |
||||
|
const features: [BrowserFeature, boolean][] = [ |
||||
|
["Web Bluetooth", !!navigator?.bluetooth], |
||||
|
["Web Serial", !!navigator?.serial], |
||||
|
[ |
||||
|
"Secure Context", |
||||
|
window.location.protocol === "https:" || |
||||
|
window.location.hostname === "localhost", |
||||
|
], |
||||
|
]; |
||||
|
|
||||
|
return features.reduce<BrowserSupport>( |
||||
|
(acc, [feature, isSupported]) => { |
||||
|
const list = isSupported ? acc.supported : acc.unsupported; |
||||
|
list.push(feature); |
||||
|
return acc; |
||||
|
}, |
||||
|
{ supported: [], unsupported: [] }, |
||||
|
); |
||||
|
}, []); |
||||
|
|
||||
|
return support; |
||||
|
} |
||||
@ -0,0 +1,52 @@ |
|||||
|
import Cookies, { type CookieAttributes } from "js-cookie"; |
||||
|
import { useCallback, useState } from "react"; |
||||
|
|
||||
|
interface CookieHookResult<T> { |
||||
|
value: T | undefined; |
||||
|
setCookie: (value: T, options?: CookieAttributes) => void; |
||||
|
removeCookie: () => void; |
||||
|
} |
||||
|
|
||||
|
function useCookie<T extends object>( |
||||
|
cookieName: string, |
||||
|
initialValue?: T, |
||||
|
): CookieHookResult<T> { |
||||
|
const [cookieValue, setCookieValue] = useState<T | undefined>(() => { |
||||
|
try { |
||||
|
const cookie = Cookies.get(cookieName); |
||||
|
return cookie ? (JSON.parse(cookie) as T) : initialValue; |
||||
|
} catch (error) { |
||||
|
console.error(`Error parsing cookie ${cookieName}:`, error); |
||||
|
return initialValue; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const setCookie = useCallback( |
||||
|
(value: T, options?: CookieAttributes) => { |
||||
|
try { |
||||
|
Cookies.set(cookieName, JSON.stringify(value), options); |
||||
|
setCookieValue(value); |
||||
|
} catch (error) { |
||||
|
console.error(`Error setting cookie ${cookieName}:`, error); |
||||
|
} |
||||
|
}, |
||||
|
[cookieName], |
||||
|
); |
||||
|
|
||||
|
const removeCookie = useCallback(() => { |
||||
|
try { |
||||
|
Cookies.remove(cookieName); |
||||
|
setCookieValue(undefined); |
||||
|
} catch (error) { |
||||
|
console.error(`Error removing cookie ${cookieName}:`, error); |
||||
|
} |
||||
|
}, [cookieName]); |
||||
|
|
||||
|
return { |
||||
|
value: cookieValue, |
||||
|
setCookie, |
||||
|
removeCookie, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export default useCookie; |
||||
@ -0,0 +1,119 @@ |
|||||
|
import { Button } from "@app/components/UI/Button"; |
||||
|
import type { CookieAttributes } from "js-cookie"; |
||||
|
import { useCallback, useEffect, useRef } from "react"; |
||||
|
import useCookie from "./useCookie"; |
||||
|
import { useToast } from "./useToast"; |
||||
|
|
||||
|
interface UseBackupReminderOptions { |
||||
|
reminderInDays?: number; |
||||
|
message: string; |
||||
|
onAccept?: () => void | Promise<void>; |
||||
|
enabled: boolean; |
||||
|
cookieOptions?: CookieAttributes; |
||||
|
} |
||||
|
|
||||
|
interface ReminderState { |
||||
|
suppressed: boolean; |
||||
|
lastShown: string; |
||||
|
} |
||||
|
|
||||
|
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds;
|
||||
|
const TOAST_DURATION = 30_000; // 30 seconds;:
|
||||
|
|
||||
|
// remind user in 1 year to backup keys again, if they accept the reminder;
|
||||
|
const ON_ACCEPT_REMINDER_DAYS = 365; |
||||
|
|
||||
|
function isReminderExpired(lastShown: string): boolean { |
||||
|
const lastShownDate = new Date(lastShown); |
||||
|
const now = new Date(); |
||||
|
const daysSinceLastShown = |
||||
|
(now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24); |
||||
|
return daysSinceLastShown >= 7; |
||||
|
} |
||||
|
|
||||
|
export function useBackupReminder({ |
||||
|
reminderInDays = 7, |
||||
|
enabled, |
||||
|
message, |
||||
|
onAccept = () => {}, |
||||
|
cookieOptions, |
||||
|
}: UseBackupReminderOptions) { |
||||
|
const { toast } = useToast(); |
||||
|
const toastShownRef = useRef(false); |
||||
|
const { value: reminderCookie, setCookie } = useCookie<ReminderState>( |
||||
|
"key_backup_reminder", |
||||
|
); |
||||
|
|
||||
|
const suppressReminder = useCallback( |
||||
|
(days: number) => { |
||||
|
const expiryDate = new Date(); |
||||
|
expiryDate.setDate(expiryDate.getDate() + days); |
||||
|
|
||||
|
setCookie( |
||||
|
{ |
||||
|
suppressed: true, |
||||
|
lastShown: new Date().toISOString(), |
||||
|
}, |
||||
|
{ ...cookieOptions, expires: expiryDate }, |
||||
|
); |
||||
|
}, |
||||
|
[setCookie, cookieOptions], |
||||
|
); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
if (!enabled || toastShownRef.current) return; |
||||
|
|
||||
|
const shouldShowReminder = |
||||
|
!reminderCookie?.suppressed || |
||||
|
isReminderExpired(reminderCookie.lastShown); |
||||
|
if (!shouldShowReminder) return; |
||||
|
|
||||
|
toastShownRef.current = true; |
||||
|
|
||||
|
const { dismiss } = toast({ |
||||
|
title: "Backup Reminder", |
||||
|
duration: TOAST_DURATION, |
||||
|
delay: TOAST_APPEAR_DELAY, |
||||
|
description: message, |
||||
|
action: ( |
||||
|
<div className="flex gap-2"> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="default" |
||||
|
onClick={() => { |
||||
|
onAccept(); |
||||
|
dismiss(); |
||||
|
suppressReminder(ON_ACCEPT_REMINDER_DAYS); |
||||
|
}} |
||||
|
> |
||||
|
Back up now |
||||
|
</Button> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="outline" |
||||
|
onClick={() => { |
||||
|
dismiss(); |
||||
|
suppressReminder(reminderInDays); |
||||
|
}} |
||||
|
> |
||||
|
Remind me in {reminderInDays} days |
||||
|
</Button> |
||||
|
</div> |
||||
|
), |
||||
|
}); |
||||
|
|
||||
|
return () => { |
||||
|
if (!toastShownRef.current) { |
||||
|
dismiss(); |
||||
|
} |
||||
|
}; |
||||
|
}, [ |
||||
|
enabled, |
||||
|
message, |
||||
|
onAccept, |
||||
|
reminderInDays, |
||||
|
suppressReminder, |
||||
|
toast, |
||||
|
reminderCookie, |
||||
|
]); |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue