Browse Source

Merge branch 'master' into chore/update-project-deps-to-latest

pull/380/head
Dan Ditomaso 1 year ago
committed by GitHub
parent
commit
71ad2d0ebd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      package.json
  2. 4
      src/App.tsx
  3. 7
      src/components/Dialog/DialogManager.tsx
  4. 134
      src/components/Dialog/PKIBackupDialog.tsx
  5. 11
      src/components/Form/FormPasswordGenerator.tsx
  6. 19
      src/components/KeyBackupReminder.tsx
  7. 18
      src/components/PageComponents/Channel.tsx
  8. 29
      src/components/PageComponents/Config/Security.tsx
  9. 25
      src/components/Toaster.tsx
  10. 2
      src/components/UI/Button.tsx
  11. 25
      src/components/UI/Generator.tsx
  12. 68
      src/components/UI/Toast.tsx
  13. 52
      src/core/hooks/useCookie.ts
  14. 120
      src/core/hooks/useKeyBackupReminder.tsx
  15. 5
      src/core/hooks/useToast.ts
  16. 5
      src/core/stores/deviceStore.ts

1
package.json

@ -49,6 +49,7 @@
"crypto-random-string": "^5.0.0", "crypto-random-string": "^5.0.0",
"immer": "^10.1.1", "immer": "^10.1.1",
"lucide-react": "^0.474.0", "lucide-react": "^0.474.0",
"js-cookie": "^3.0.5",
"mapbox-gl": "^3.6.0", "mapbox-gl": "^3.6.0",
"maplibre-gl": "4.1.2", "maplibre-gl": "4.1.2",
"react": "^19.0.0", "react": "^19.0.0",

4
src/App.tsx

@ -2,7 +2,7 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
import { PageRouter } from "@app/PageRouter.tsx"; import { PageRouter } from "@app/PageRouter.tsx";
import { CommandPalette } from "@components/CommandPalette.tsx"; import { CommandPalette } from "@components/CommandPalette.tsx";
import { DeviceSelector } from "@components/DeviceSelector.tsx"; import { DeviceSelector } from "@components/DeviceSelector.tsx";
import { DialogManager } from "@components/Dialog/DialogManager.tsx"; import { DialogManager } from "@components/Dialog/DialogManager";
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx"; import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
import { Toaster } from "@components/Toaster.tsx"; import { Toaster } from "@components/Toaster.tsx";
import Footer from "@components/UI/Footer.tsx"; import Footer from "@components/UI/Footer.tsx";
@ -11,6 +11,7 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts"; import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx"; import { Dashboard } from "@pages/Dashboard/index.tsx";
import { MapProvider } from "react-map-gl"; import { MapProvider } from "react-map-gl";
import { KeyBackupReminder } from "@components/KeyBackupReminder";
export const App = (): JSX.Element => { export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore(); const { getDevice } = useDeviceStore();
@ -37,6 +38,7 @@ export const App = (): JSX.Element => {
{device ? ( {device ? (
<div className="flex h-screen"> <div className="flex h-screen">
<DialogManager /> <DialogManager />
<KeyBackupReminder />
<CommandPalette /> <CommandPalette />
<PageRouter /> <PageRouter />
</div> </div>

7
src/components/Dialog/DialogManager.tsx

@ -5,6 +5,7 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx";
import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx";
import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog";
export const DialogManager = (): JSX.Element => { export const DialogManager = (): JSX.Element => {
const { channels, config, dialog, setDialogOpen } = useDevice(); const { channels, config, dialog, setDialogOpen } = useDevice();
@ -49,6 +50,12 @@ export const DialogManager = (): JSX.Element => {
setDialogOpen("nodeRemoval", open); setDialogOpen("nodeRemoval", open);
}} }}
/> />
<PkiBackupDialog
open={dialog.pkiBackup}
onOpenChange={(open) => {
setDialogOpen("pkiBackup", open);
}}
/>
</> </>
); );
}; };

134
src/components/Dialog/PKIBackupDialog.tsx

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

11
src/components/Form/FormPasswordGenerator.tsx

@ -7,6 +7,7 @@ import { Eye, EyeOff } from "lucide-react";
import type { ChangeEventHandler, MouseEventHandler } from "react"; import type { ChangeEventHandler, MouseEventHandler } from "react";
import { useState } from "react"; import { useState } from "react";
import { Controller, type FieldValues } from "react-hook-form"; import { Controller, type FieldValues } from "react-hook-form";
import type { ButtonVariant } from "@components/UI/Button";
export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> { export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
type: "passwordGenerator"; type: "passwordGenerator";
@ -15,7 +16,12 @@ export interface PasswordGeneratorProps<T> extends BaseFormBuilderProps<T> {
devicePSKBitCount: number; devicePSKBitCount: number;
inputChange: ChangeEventHandler; inputChange: ChangeEventHandler;
selectChange: (event: string) => void; selectChange: (event: string) => void;
buttonClick: MouseEventHandler; actionButtons: {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[];
} }
export function PasswordGenerator<T extends FieldValues>({ export function PasswordGenerator<T extends FieldValues>({
@ -47,10 +53,9 @@ export function PasswordGenerator<T extends FieldValues>({
bits={field.bits} bits={field.bits}
inputChange={field.inputChange} inputChange={field.inputChange}
selectChange={field.selectChange} selectChange={field.selectChange}
buttonClick={field.buttonClick}
value={value} value={value}
variant={field.validationText ? "invalid" : "default"} variant={field.validationText ? "invalid" : "default"}
buttonText="Generate" actionButtons={field.actionButtons}
{...field.properties} {...field.properties}
{...rest} {...rest}
disabled={disabled} disabled={disabled}

19
src/components/KeyBackupReminder.tsx

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

18
src/components/PageComponents/Channel.tsx

@ -6,6 +6,7 @@ import { Protobuf } from "@meshtastic/js";
import { fromByteArray, toByteArray } from "base64-js"; import { fromByteArray, toByteArray } from "base64-js";
import cryptoRandomString from "crypto-random-string"; import cryptoRandomString from "crypto-random-string";
import { useState } from "react"; import { useState } from "react";
import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog";
export interface SettingsPanelProps { export interface SettingsPanelProps {
channel: Protobuf.Channel.Channel; channel: Protobuf.Channel.Channel;
@ -22,6 +23,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
channel?.settings?.psk.length ?? 16, channel?.settings?.psk.length ?? 16,
); );
const [validationText, setValidationText] = useState<string>(); const [validationText, setValidationText] = useState<string>();
const [preSharedDialogOpen, setPreSharedDialogOpen] = useState<boolean>(false);
const onSubmit = (data: ChannelValidation) => { const onSubmit = (data: ChannelValidation) => {
const channel = new Protobuf.Channel.Channel({ const channel = new Protobuf.Channel.Channel({
@ -46,7 +48,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}); });
}; };
const clickEvent = () => { const preSharedKeyRegenerate = () => {
setPass( setPass(
btoa( btoa(
cryptoRandomString({ cryptoRandomString({
@ -56,6 +58,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
), ),
); );
setValidationText(undefined); setValidationText(undefined);
setPreSharedDialogOpen(false);
};
const preSharedClickEvent = () => {
setPreSharedDialogOpen(true);
}; };
const validatePass = (input: string, count: number) => { const validatePass = (input: string, count: number) => {
@ -79,6 +86,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}; };
return ( return (
<>
<DynamicForm<ChannelValidation> <DynamicForm<ChannelValidation>
onSubmit={onSubmit} onSubmit={onSubmit}
submitType="onSubmit" submitType="onSubmit"
@ -130,7 +138,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
devicePSKBitCount: bitCount ?? 0, devicePSKBitCount: bitCount ?? 0,
inputChange: inputChangeEvent, inputChange: inputChangeEvent,
selectChange: selectChangeEvent, selectChange: selectChangeEvent,
buttonClick: clickEvent, actionButtons: [{ text: 'Generate', variant: 'success', onClick: preSharedClickEvent }],
hide: true, hide: true,
properties: { properties: {
value: pass, value: pass,
@ -206,5 +214,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}, },
]} ]}
/> />
<PkiRegenerateDialog
open={preSharedDialogOpen}
onOpenChange={() => setPreSharedDialogOpen(false)}
onSubmit={() => preSharedKeyRegenerate()}
/>
</>
); );
}; };

29
src/components/PageComponents/Config/Security.tsx

@ -12,7 +12,7 @@ import { Eye, EyeOff } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export const Security = (): JSX.Element => { export const Security = (): JSX.Element => {
const { config, nodes, hardware, setWorkingConfig } = useDevice(); const { config, nodes, hardware, setWorkingConfig, setDialogOpen } = useDevice();
const [privateKey, setPrivateKey] = useState<string>( const [privateKey, setPrivateKey] = useState<string>(
fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), fromByteArray(config.security?.privateKey ?? new Uint8Array(0)),
@ -31,7 +31,7 @@ export const Security = (): JSX.Element => {
); );
const [adminKeyValidationText, setAdminKeyValidationText] = const [adminKeyValidationText, setAdminKeyValidationText] =
useState<string>(); useState<string>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState<boolean>(false);
const onSubmit = (data: SecurityValidation) => { const onSubmit = (data: SecurityValidation) => {
if (privateKeyValidationText || adminKeyValidationText) return; if (privateKeyValidationText || adminKeyValidationText) return;
@ -71,9 +71,13 @@ export const Security = (): JSX.Element => {
}; };
const privateKeyClickEvent = () => { const privateKeyClickEvent = () => {
setDialogOpen(true); setPrivateKeyDialogOpen(true);
}; };
const pkiBackupClickEvent = () => {
setDialogOpen("pkiBackup", true);
}
const pkiRegenerate = () => { const pkiRegenerate = () => {
const privateKey = getX25519PrivateKey(); const privateKey = getX25519PrivateKey();
const publicKey = getX25519PublicKey(privateKey); const publicKey = getX25519PublicKey(privateKey);
@ -86,7 +90,7 @@ export const Security = (): JSX.Element => {
setPrivateKeyValidationText, setPrivateKeyValidationText,
); );
setDialogOpen(false); setPrivateKeyDialogOpen(false);
}; };
const privateKeyInputChangeEvent = ( const privateKeyInputChangeEvent = (
@ -149,7 +153,18 @@ export const Security = (): JSX.Element => {
inputChange: privateKeyInputChangeEvent, inputChange: privateKeyInputChangeEvent,
selectChange: privateKeySelectChangeEvent, selectChange: privateKeySelectChangeEvent,
hide: !privateKeyVisible, hide: !privateKeyVisible,
buttonClick: privateKeyClickEvent, actionButtons: [
{
text: "Generate",
onClick: privateKeyClickEvent,
variant: "success",
},
{
text: "Backup Key",
onClick: pkiBackupClickEvent,
variant: "subtle",
},
],
properties: { properties: {
value: privateKey, value: privateKey,
action: { action: {
@ -228,8 +243,8 @@ export const Security = (): JSX.Element => {
]} ]}
/> />
<PkiRegenerateDialog <PkiRegenerateDialog
open={dialogOpen} open={privateKeyDialogOpen}
onOpenChange={() => setDialogOpen(false)} onOpenChange={() => setPrivateKeyDialogOpen(false)}
onSubmit={() => pkiRegenerate()} onSubmit={() => pkiRegenerate()}
/> />
</> </>

25
src/components/Toaster.tsx

@ -1,5 +1,3 @@
import { useToast } from "@core/hooks/useToast.ts";
import { import {
Toast, Toast,
ToastClose, ToastClose,
@ -7,24 +5,25 @@ import {
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport,
} from "@components/UI/Toast.tsx"; } from "@components/UI/Toast";
import { useToast } from "@core/hooks/useToast";
export function Toaster() { export function Toaster() {
const { toasts } = useToast(); const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => ( {toasts.map(({ id, title, description, action, duration, ...props }) => (
<Toast key={id} {...props}> <Toast
key={id}
{...props}
duration={duration}
className="flex flex-col gap-4"
>
<div className="grid gap-1"> <div className="grid gap-1">
{title && ( {title && <ToastTitle>{title}</ToastTitle>}
<ToastTitle className="dark:text-white">{title}</ToastTitle> {description && <ToastDescription>{description}</ToastDescription>}
)}
{description && (
<ToastDescription className="dark:text-white-400">
{description}
</ToastDescription>
)}
</div> </div>
{action} {action}
<ToastClose /> <ToastClose />

2
src/components/UI/Button.tsx

@ -35,6 +35,8 @@ const buttonVariants = cva(
}, },
); );
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { } VariantProps<typeof buttonVariants> { }

25
src/components/UI/Generator.tsx

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Button } from "@components/UI/Button.tsx"; import { Button, type ButtonVariant } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
import { import {
Select, Select,
@ -16,11 +16,15 @@ export interface GeneratorProps extends React.BaseHTMLAttributes<HTMLElement> {
devicePSKBitCount?: number; devicePSKBitCount?: number;
value: string; value: string;
variant: "default" | "invalid"; variant: "default" | "invalid";
buttonText?: string; actionButtons: {
text: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
variant: ButtonVariant;
className?: string;
}[];
bits?: { text: string; value: string; key: string }[]; bits?: { text: string; value: string; key: string }[];
selectChange: (event: string) => void; selectChange: (event: string) => void;
inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void; inputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonClick: React.MouseEventHandler<HTMLButtonElement>;
action?: { action?: {
icon: LucideIcon; icon: LucideIcon;
onClick: () => void; onClick: () => void;
@ -35,7 +39,7 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
devicePSKBitCount, devicePSKBitCount,
variant, variant,
value, value,
buttonText, actionButtons,
bits = [ bits = [
{ text: "256 bit", value: "32", key: "bit256" }, { text: "256 bit", value: "32", key: "bit256" },
{ text: "128 bit", value: "16", key: "bit128" }, { text: "128 bit", value: "16", key: "bit128" },
@ -44,7 +48,6 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
], ],
selectChange, selectChange,
inputChange, inputChange,
buttonClick,
action, action,
disabled, disabled,
...props ...props
@ -94,15 +97,21 @@ const Generator = React.forwardRef<HTMLInputElement, GeneratorProps>(
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex ml-4 space-x-4">
{actionButtons?.map(({ text, onClick, variant, className }) => (
<Button <Button
key={text}
type="button" type="button"
variant="success" onClick={onClick}
onClick={buttonClick}
disabled={disabled} disabled={disabled}
variant={variant}
className={className}
{...props} {...props}
> >
{buttonText} {text}
</Button> </Button>
))}
</div>
</> </>
); );
}, },

68
src/components/UI/Toast.tsx

@ -1,11 +1,11 @@
import * as ToastPrimitives from "@radix-ui/react-toast"; import * as React from "react"
import { type VariantProps, cva } from "class-variance-authority"; import * as ToastPrimitives from "@radix-ui/react-toast"
import { X } from "lucide-react"; import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"; import { X } from 'lucide-react'
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn"
const ToastProvider = ToastPrimitives.Provider; const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
@ -14,30 +14,29 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-50 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-24 sm:right-6 sm:top-auto sm:flex-col md:max-w-[420px]",
className, className
)} )}
{...props} {...props}
/> />
)); ))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName; ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva( const toastVariants = cva(
"data-[swipe=move]:transition-none grow-1 group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full mt-4 data-[state=closed]:slide-out-to-right-full dark:border-slate-700 last:mt-0 sm:last:mt-4", "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{ {
variants: { variants: {
variant: { variant: {
default: default: "border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50",
"bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700",
destructive: destructive:
"group destructive bg-red-600 text-white border-red-600 dark:border-red-600", "group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50"
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
}, }
); )
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
@ -50,9 +49,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
); )
}); })
Toast.displayName = ToastPrimitives.Root.displayName; Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
@ -61,13 +60,13 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium transition-colors hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-red-100 group-[.destructive]:hover:border-slate-50 group-[.destructive]:hover:bg-red-100 group-[.destructive]:hover:text-red-600 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-700 dark:hover:text-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=open]:bg-slate-800", "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className, className
)} )}
{...props} {...props}
/> />
)); ))
ToastAction.displayName = ToastPrimitives.Action.displayName; ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
@ -76,16 +75,16 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
"absolute right-2 top-2 rounded-md p-1 text-slate-500 opacity-0 transition-opacity hover:text-slate-900 focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:hover:text-slate-50", "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-slate-400 dark:hover:text-slate-50",
className, className
)} )}
toast-close="" toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)); ))
ToastClose.displayName = ToastPrimitives.Close.displayName; ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
@ -96,8 +95,8 @@ const ToastTitle = React.forwardRef<
className={cn("text-sm font-semibold", className)} className={cn("text-sm font-semibold", className)}
{...props} {...props}
/> />
)); ))
ToastTitle.displayName = ToastPrimitives.Title.displayName; ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
@ -108,12 +107,12 @@ const ToastDescription = React.forwardRef<
className={cn("text-sm opacity-90", className)} className={cn("text-sm opacity-90", className)}
{...props} {...props}
/> />
)); ))
ToastDescription.displayName = ToastPrimitives.Description.displayName; ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>; type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>; type ToastActionElement = React.ReactElement<typeof ToastAction>
export { export {
type ToastProps, type ToastProps,
@ -125,4 +124,5 @@ export {
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
}; }

52
src/core/hooks/useCookie.ts

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

120
src/core/hooks/useKeyBackupReminder.tsx

@ -0,0 +1,120 @@
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,
]);
}

5
src/core/hooks/useToast.ts

@ -10,6 +10,7 @@ type ToasterToast = ToastProps & {
title?: ReactNode; title?: ReactNode;
description?: ReactNode; description?: ReactNode;
action?: ToastActionElement; action?: ToastActionElement;
delay?: number;
}; };
const actionTypes = { const actionTypes = {
@ -137,7 +138,7 @@ function dispatch(action: Action) {
type Toast = Omit<ToasterToast, "id">; type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ delay = 0, ...props }: Toast) {
const id = genId(); const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
@ -147,6 +148,7 @@ function toast({ ...props }: Toast) {
}); });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
setTimeout(() => {
dispatch({ dispatch({
type: "ADD_TOAST", type: "ADD_TOAST",
toast: { toast: {
@ -158,6 +160,7 @@ function toast({ ...props }: Toast) {
}, },
}, },
}); });
}, delay);
return { return {
id: id, id: id,

5
src/core/stores/deviceStore.ts

@ -25,7 +25,8 @@ export type DialogVariant =
| "shutdown" | "shutdown"
| "reboot" | "reboot"
| "deviceName" | "deviceName"
| "nodeRemoval"; | "nodeRemoval"
| "pkiBackup";
export interface Device { export interface Device {
id: number; id: number;
@ -60,6 +61,7 @@ export interface Device {
reboot: boolean; reboot: boolean;
deviceName: boolean; deviceName: boolean;
nodeRemoval: boolean; nodeRemoval: boolean;
pkiBackup: boolean;
}; };
setStatus: (status: Types.DeviceStatusEnum) => void; setStatus: (status: Types.DeviceStatusEnum) => void;
@ -142,6 +144,7 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
reboot: false, reboot: false,
deviceName: false, deviceName: false,
nodeRemoval: false, nodeRemoval: false,
pkiBackup: false,
}, },
pendingSettingsChanges: false, pendingSettingsChanges: false,
messageDraft: "", messageDraft: "",

Loading…
Cancel
Save