diff --git a/package.json b/package.json index b1b7326f..e56bdaed 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "crypto-random-string": "^5.0.0", "immer": "^10.1.1", "lucide-react": "^0.363.0", "mapbox-gl": "npm:empty-npm-package@^1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b94369f4..938ea82e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: cmdk: specifier: ^1.0.0 version: 1.0.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) + crypto-random-string: + specifier: ^5.0.0 + version: 5.0.0 immer: specifier: ^10.1.1 version: 10.1.1 @@ -1901,6 +1904,10 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + crypto-random-string@5.0.0: + resolution: {integrity: sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==} + engines: {node: '>=14.16'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2929,6 +2936,10 @@ packages: turf-jsts@1.2.3: resolution: {integrity: sha512-Ja03QIJlPuHt4IQ2FfGex4F4JAr8m3jpaHbFbQrgwr7s7L6U8ocrHiF3J1+wf9jzhGKxvDeaCAnGDot8OjGFyA==} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + typescript@5.5.2: resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} engines: {node: '>=14.17'} @@ -5293,6 +5304,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-random-string@5.0.0: + dependencies: + type-fest: 2.19.0 + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -6365,6 +6380,8 @@ snapshots: turf-jsts@1.2.3: {} + type-fest@2.19.0: {} + typescript@5.5.2: {} typewise-core@1.2.0: {} diff --git a/src/components/Form/DynamicForm.tsx b/src/components/Form/DynamicForm.tsx index 4c9b551b..61c4c0e7 100644 --- a/src/components/Form/DynamicForm.tsx +++ b/src/components/Form/DynamicForm.tsx @@ -26,6 +26,7 @@ export interface BaseFormBuilderProps { disabledBy?: DisabledBy[]; label: string; description?: string; + validationText?: string; properties?: Record; } @@ -39,11 +40,12 @@ export interface DynamicFormProps { onSubmit: SubmitHandler; submitType?: "onChange" | "onSubmit"; hasSubmitButton?: boolean; - // defaultValues?: DeepPartial; defaultValues?: DefaultValues; fieldGroups: { label: string; description: string; + valid?: boolean; + validationText?: string; fields: FieldProps[]; }[]; } @@ -98,6 +100,11 @@ export function DynamicForm({ key={field.label} label={field.label} description={field.description} + valid={ + field.validationText === undefined || + field.validationText === "" + } + validationText={field.validationText} > = | InputFieldProps | SelectFieldProps - | ToggleFieldProps; + | ToggleFieldProps + | PasswordGeneratorProps; export interface DynamicFormFieldProps { field: FieldProps; @@ -44,6 +49,14 @@ export function DynamicFormField({ return ( ); + case "passwordGenerator": + return ( + + ); case "multiSelect": return
tmp
; } diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx new file mode 100644 index 00000000..d015d3af --- /dev/null +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -0,0 +1,40 @@ +import type { + BaseFormBuilderProps, + GenericFormElementProps, +} from "@components/Form/DynamicForm.js"; +import { Generator } from "@components/UI/Generator.js"; +import type { ChangeEventHandler, MouseEventHandler } from "react"; +import { Controller, type FieldValues } from "react-hook-form"; + +export interface PasswordGeneratorProps extends BaseFormBuilderProps { + type: "passwordGenerator"; + devicePSKBitCount: number; + inputChange: ChangeEventHandler; + selectChange: (event: string) => void; + buttonClick: MouseEventHandler; +} + +export function PasswordGenerator({ + control, + field, +}: GenericFormElementProps>) { + return ( + ( + + )} + /> + ); +} diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 3f58d524..87f8aec2 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -5,12 +5,16 @@ export interface FieldWrapperProps { description?: string; disabled?: boolean; children?: React.ReactNode; + valid?: boolean; + validationText?: string; } export const FieldWrapper = ({ label, description, children, + valid, + validationText, }: FieldWrapperProps): JSX.Element => (
@@ -19,6 +23,9 @@ export const FieldWrapper = ({

{description}

+
{children}
diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index 464d04e7..d90e950b 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -4,6 +4,8 @@ import { useToast } from "@core/hooks/useToast.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 { useState } from "react"; export interface SettingsPanelProps { channel: Protobuf.Channel.Channel; @@ -13,12 +15,20 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { const { config, connection, addChannel } = useDevice(); const { toast } = useToast(); + const [pass, setPass] = useState( + fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), + ); + const [bitCount, setBits] = useState( + channel?.settings?.psk.length ?? 16, + ); + const [validationText, setValidationText] = useState(); + const onSubmit = (data: ChannelValidation) => { const channel = new Protobuf.Channel.Channel({ ...data, settings: { ...data.settings, - psk: toByteArray(data.settings.psk ?? ""), + psk: toByteArray(pass), moduleSettings: { positionPrecision: data.settings.positionEnabled ? data.settings.preciseLocation @@ -36,6 +46,38 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { }); }; + const clickEvent = () => { + setPass( + btoa( + cryptoRandomString({ + length: bitCount ?? 0, + type: "alphanumeric", + }), + ), + ); + setValidationText(undefined); + }; + + const validatePass = (input: string, count: number) => { + if (input.length % 4 !== 0 || toByteArray(input).length !== count) { + setValidationText(`Please enter a valid ${count * 8} bit PSK.`); + } else { + setValidationText(undefined); + } + }; + + const inputChangeEvent = (e: React.ChangeEvent) => { + const psk = e.currentTarget?.value; + setPass(psk); + validatePass(psk, bitCount); + }; + + const selectChangeEvent = (e: string) => { + const count = Number.parseInt(e); + setBits(count); + validatePass(pass, count); + }; + return ( onSubmit={onSubmit} @@ -46,7 +88,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { ...{ settings: { ...channel?.settings, - psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0)), + psk: pass, positionEnabled: channel?.settings?.moduleSettings?.positionPrecision !== undefined && @@ -76,12 +118,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { }, }, { - type: "password", + type: "passwordGenerator", name: "settings.psk", label: "pre-Shared Key", - description: "16, or 32 bytes", + description: "256, 128, or 8 bit PSKs allowed", + validationText: validationText, + devicePSKBitCount: bitCount ?? 0, + inputChange: inputChangeEvent, + selectChange: selectChangeEvent, + buttonClick: clickEvent, properties: { - // act + value: pass, }, }, { diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index cea50fea..f903f25e 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -12,6 +12,8 @@ const buttonVariants = cva( "bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900", destructive: "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", + success: + "bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600", outline: "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", subtle: diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx new file mode 100644 index 00000000..344e89bb --- /dev/null +++ b/src/components/UI/Generator.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; + +import { Button } from "@components/UI/Button.js"; +import { Input } from "@components/UI/Input.js"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/UI/Select.js"; + +export interface GeneratorProps extends React.BaseHTMLAttributes { + devicePSKBitCount?: number; + value: string; + variant: "default" | "invalid"; + buttonText?: string; + selectChange: (event: string) => void; + inputChange: (event: React.ChangeEvent) => void; + buttonClick: React.MouseEventHandler; +} + +const Generator = React.forwardRef( + ( + { + devicePSKBitCount, + variant, + value, + buttonText, + selectChange, + inputChange, + buttonClick, + ...props + }, + ref, + ) => { + return ( + <> + + + + + ); + }, +); +Generator.displayName = "Button"; + +export { Generator }; diff --git a/src/components/UI/Input.tsx b/src/components/UI/Input.tsx index e13f25f4..2c3080fb 100644 --- a/src/components/UI/Input.tsx +++ b/src/components/UI/Input.tsx @@ -1,10 +1,27 @@ import * as React from "react"; import { cn } from "@core/utils/cn.js"; +import { type VariantProps, cva } from "class-variance-authority"; import type { LucideIcon } from "lucide-react"; +const inputVariants = cva( + "flex h-10 w-full rounded-md border bg-transparent py-2 px-3 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900", + { + variants: { + variant: { + default: "border-slate-300 dark:border-slate-700", + invalid: "border-red-500 dark:border-red-500", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + export interface InputProps - extends React.InputHTMLAttributes { + extends React.InputHTMLAttributes, + VariantProps { prefix?: string; suffix?: string; action?: { @@ -14,7 +31,7 @@ export interface InputProps } const Input = React.forwardRef( - ({ className, prefix, suffix, action, ...props }, ref) => { + ({ className, variant, prefix, suffix, action, ...props }, ref) => { return (
{prefix && ( @@ -24,9 +41,9 @@ const Input = React.forwardRef( )} ( ); Input.displayName = "Input"; -export { Input }; +export { Input, inputVariants };