From db09711be55bb4cda35692ca48b010f4584e71cf Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 3 Jan 2025 12:05:29 -0500 Subject: [PATCH 1/3] feat: Add pki backup dialog, refactor Channels pre-shared key to support regenerate dialog --- src/components/Dialog/DialogManager.tsx | 7 + src/components/Dialog/PKIBackupDialog.tsx | 104 +++++++++ src/components/Form/FormPasswordGenerator.tsx | 17 +- src/components/PageComponents/Channel.tsx | 216 ++++++++++-------- .../PageComponents/Config/Security.tsx | 29 ++- src/components/UI/Button.tsx | 4 +- src/components/UI/Generator.tsx | 37 +-- src/core/stores/deviceStore.ts | 5 +- 8 files changed, 289 insertions(+), 130 deletions(-) create mode 100644 src/components/Dialog/PKIBackupDialog.tsx diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index 16e60120..762bd5a7 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -5,6 +5,7 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import { PkiBackupDialog } from "./PKIBackupDialog"; export const DialogManager = (): JSX.Element => { const { channels, config, dialog, setDialogOpen } = useDevice(); @@ -49,6 +50,12 @@ export const DialogManager = (): JSX.Element => { setDialogOpen("nodeRemoval", open); }} /> + { + setDialogOpen("pkiBackup", open); + }} + /> ); }; diff --git a/src/components/Dialog/PKIBackupDialog.tsx b/src/components/Dialog/PKIBackupDialog.tsx new file mode 100644 index 00000000..3737236a --- /dev/null +++ b/src/components/Dialog/PKIBackupDialog.tsx @@ -0,0 +1,104 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@components/UI/Dialog.tsx"; +import { Button } from "@components/UI/Button"; +import { DownloadIcon, PrinterIcon } from "lucide-react"; +import React from "react"; +import { useDevice } from "@app/core/stores/deviceStore"; +import { fromByteArray } from "base64-js"; + +export interface PkiBackupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const PkiBackupDialog = ({ + open, + onOpenChange, +}: PkiBackupDialogProps) => { + const { config } = useDevice(); + const privateKeyData = config.security?.privateKey + + // If the private data doesn't exist return null + if (!privateKeyData) { + return null + } + + const getPrivateKey = React.useMemo(() => fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), [config.security?.privateKey]); + + const renderPrintWindow = React.useCallback(() => { + const printWindow = window.open("", "_blank"); + if (printWindow) { + printWindow.document.write(` + + + Your Private Key + + + +

Your Private Key

+

${getPrivateKey}

+ + + `); + printWindow.document.close(); + printWindow.print(); + } + }, [getPrivateKey]); + + const createDownloadKeyFile = React.useCallback(() => { + const blob = new Blob([getPrivateKey], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "meshtastic_private_key.txt"; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [getPrivateKey]); + + + return ( + + + + Backup Key + + Its important to backup your private key and store your backup securely! + + + If you lose your private key, you will need to reset your device. + + + + + + + + + ); +}; diff --git a/src/components/Form/FormPasswordGenerator.tsx b/src/components/Form/FormPasswordGenerator.tsx index 09e97ff5..784086fb 100644 --- a/src/components/Form/FormPasswordGenerator.tsx +++ b/src/components/Form/FormPasswordGenerator.tsx @@ -7,6 +7,7 @@ import { Eye, EyeOff } from "lucide-react"; import type { ChangeEventHandler, MouseEventHandler } from "react"; import { useState } from "react"; import { Controller, type FieldValues } from "react-hook-form"; +import type { ButtonVariant } from "@components/UI/Button"; export interface PasswordGeneratorProps extends BaseFormBuilderProps { type: "passwordGenerator"; @@ -15,7 +16,12 @@ export interface PasswordGeneratorProps extends BaseFormBuilderProps { devicePSKBitCount: number; inputChange: ChangeEventHandler; selectChange: (event: string) => void; - buttonClick: MouseEventHandler; + actionButtons: { + text: string; + onClick: React.MouseEventHandler; + variant: ButtonVariant; + className?: string; + }[]; } export function PasswordGenerator({ @@ -38,19 +44,18 @@ export function PasswordGenerator({ action={ field.hide ? { - icon: passwordShown ? EyeOff : Eye, - onClick: togglePasswordVisiblity, - } + icon: passwordShown ? EyeOff : Eye, + onClick: togglePasswordVisiblity, + } : undefined } devicePSKBitCount={field.devicePSKBitCount} bits={field.bits} inputChange={field.inputChange} selectChange={field.selectChange} - buttonClick={field.buttonClick} value={value} variant={field.validationText ? "invalid" : "default"} - buttonText="Generate" + actionButtons={field.actionButtons} {...field.properties} {...rest} disabled={disabled} diff --git a/src/components/PageComponents/Channel.tsx b/src/components/PageComponents/Channel.tsx index 3655e5f8..7b581d72 100644 --- a/src/components/PageComponents/Channel.tsx +++ b/src/components/PageComponents/Channel.tsx @@ -6,6 +6,7 @@ import { Protobuf } from "@meshtastic/js"; import { fromByteArray, toByteArray } from "base64-js"; import cryptoRandomString from "crypto-random-string"; import { useState } from "react"; +import { PkiRegenerateDialog } from "../Dialog/PkiRegenerateDialog"; export interface SettingsPanelProps { channel: Protobuf.Channel.Channel; @@ -22,6 +23,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { channel?.settings?.psk.length ?? 16, ); const [validationText, setValidationText] = useState(); + const [preSharedDialogOpen, setPreSharedDialogOpen] = useState(false); const onSubmit = (data: ChannelValidation) => { const channel = new Protobuf.Channel.Channel({ @@ -46,7 +48,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { }); }; - const clickEvent = () => { + const preSharedKeyRegenerate = () => { setPass( btoa( cryptoRandomString({ @@ -56,6 +58,11 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { ), ); setValidationText(undefined); + setPreSharedDialogOpen(false); + }; + + const preSharedClickEvent = () => { + setPreSharedDialogOpen(true); }; const validatePass = (input: string, count: number) => { @@ -79,104 +86,105 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { }; return ( - - onSubmit={onSubmit} - submitType="onSubmit" - hasSubmitButton={true} - defaultValues={{ - ...channel, - ...{ - settings: { - ...channel?.settings, - psk: pass, - positionEnabled: - channel?.settings?.moduleSettings?.positionPrecision !== + <> + + onSubmit={onSubmit} + submitType="onSubmit" + hasSubmitButton={true} + defaultValues={{ + ...channel, + ...{ + settings: { + ...channel?.settings, + psk: pass, + positionEnabled: + channel?.settings?.moduleSettings?.positionPrecision !== undefined && - channel?.settings?.moduleSettings?.positionPrecision > 0, - preciseLocation: - channel?.settings?.moduleSettings?.positionPrecision === 32, - positionPrecision: - channel?.settings?.moduleSettings?.positionPrecision === undefined - ? 10 - : channel?.settings?.moduleSettings?.positionPrecision, + channel?.settings?.moduleSettings?.positionPrecision > 0, + preciseLocation: + channel?.settings?.moduleSettings?.positionPrecision === 32, + positionPrecision: + channel?.settings?.moduleSettings?.positionPrecision === undefined + ? 10 + : channel?.settings?.moduleSettings?.positionPrecision, + }, }, - }, - }} - fieldGroups={[ - { - label: "Channel Settings", - description: "Crypto, MQTT & misc settings", - fields: [ - { - type: "select", - name: "role", - label: "Role", - disabled: channel.index === 0, - description: - "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", - properties: { - enumValue: - channel.index === 0 - ? { PRIMARY: 1 } - : { DISABLED: 0, SECONDARY: 2 }, + }} + fieldGroups={[ + { + label: "Channel Settings", + description: "Crypto, MQTT & misc settings", + fields: [ + { + type: "select", + name: "role", + label: "Role", + disabled: channel.index === 0, + description: + "Device telemetry is sent over PRIMARY. Only one PRIMARY allowed", + properties: { + enumValue: + channel.index === 0 + ? { PRIMARY: 1 } + : { DISABLED: 0, SECONDARY: 2 }, + }, }, - }, - { - type: "passwordGenerator", - name: "settings.psk", - label: "pre-Shared Key", - description: "256, 128, or 8 bit PSKs allowed", - validationText: validationText, - devicePSKBitCount: bitCount ?? 0, - inputChange: inputChangeEvent, - selectChange: selectChangeEvent, - buttonClick: clickEvent, - hide: true, - properties: { - value: pass, + { + type: "passwordGenerator", + name: "settings.psk", + label: "Pre-Shared Key", + description: "256, 128, or 8 bit PSKs allowed", + validationText: validationText, + devicePSKBitCount: bitCount ?? 0, + inputChange: inputChangeEvent, + selectChange: selectChangeEvent, + actionButtons: [{ text: 'Generate', variant: 'success', onClick: preSharedClickEvent }], + hide: true, + properties: { + value: pass, + }, }, - }, - { - type: "text", - name: "settings.name", - label: "Name", - description: - "A unique name for the channel <12 bytes, leave blank for default", - }, - { - type: "toggle", - name: "settings.uplinkEnabled", - label: "Uplink Enabled", - description: "Send messages from the local mesh to MQTT", - }, - { - type: "toggle", - name: "settings.downlinkEnabled", - label: "Downlink Enabled", - description: "Send messages from MQTT to the local mesh", - }, - { - type: "toggle", - name: "settings.positionEnabled", - label: "Allow Position Requests", - description: "Send position to channel", - }, - { - type: "toggle", - name: "settings.preciseLocation", - label: "Precise Location", - description: "Send precise location to channel", - }, - { - type: "select", - name: "settings.positionPrecision", - label: "Approximate Location", - description: - "If not sharing precise location, position shared on channel will be accurate within this distance", - properties: { - enumValue: - config.display?.units === 0 - ? { + { + type: "text", + name: "settings.name", + label: "Name", + description: + "A unique name for the channel <12 bytes, leave blank for default", + }, + { + type: "toggle", + name: "settings.uplinkEnabled", + label: "Uplink Enabled", + description: "Send messages from the local mesh to MQTT", + }, + { + type: "toggle", + name: "settings.downlinkEnabled", + label: "Downlink Enabled", + description: "Send messages from MQTT to the local mesh", + }, + { + type: "toggle", + name: "settings.positionEnabled", + label: "Allow Position Requests", + description: "Send position to channel", + }, + { + type: "toggle", + name: "settings.preciseLocation", + label: "Precise Location", + description: "Send precise location to channel", + }, + { + type: "select", + name: "settings.positionPrecision", + label: "Approximate Location", + description: + "If not sharing precise location, position shared on channel will be accurate within this distance", + properties: { + enumValue: + config.display?.units === 0 + ? { "Within 23 km": 10, "Within 12 km": 11, "Within 5.8 km": 12, @@ -188,7 +196,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { "Within 90 m": 18, "Within 50 m": 19, } - : { + : { "Within 15 miles": 10, "Within 7.3 miles": 11, "Within 3.6 miles": 12, @@ -200,11 +208,17 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => { "Within 300 feet": 18, "Within 150 feet": 19, }, + }, }, - }, - ], - }, - ]} - /> + ], + }, + ]} + /> + setPreSharedDialogOpen(false)} + onSubmit={() => preSharedKeyRegenerate()} + /> + ); }; diff --git a/src/components/PageComponents/Config/Security.tsx b/src/components/PageComponents/Config/Security.tsx index cf675751..d6e76cc9 100644 --- a/src/components/PageComponents/Config/Security.tsx +++ b/src/components/PageComponents/Config/Security.tsx @@ -12,7 +12,7 @@ import { Eye, EyeOff } from "lucide-react"; import { useState } from "react"; export const Security = (): JSX.Element => { - const { config, nodes, hardware, setWorkingConfig } = useDevice(); + const { config, nodes, hardware, setWorkingConfig, setDialogOpen } = useDevice(); const [privateKey, setPrivateKey] = useState( fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), @@ -31,7 +31,7 @@ export const Security = (): JSX.Element => { ); const [adminKeyValidationText, setAdminKeyValidationText] = useState(); - const [dialogOpen, setDialogOpen] = useState(false); + const [privateKeyDialogOpen, setPrivateKeyDialogOpen] = useState(false); const onSubmit = (data: SecurityValidation) => { if (privateKeyValidationText || adminKeyValidationText) return; @@ -71,9 +71,13 @@ export const Security = (): JSX.Element => { }; const privateKeyClickEvent = () => { - setDialogOpen(true); + setPrivateKeyDialogOpen(true); }; + const pkiBackupClickEvent = () => { + setDialogOpen("pkiBackup", true); + } + const pkiRegenerate = () => { const privateKey = getX25519PrivateKey(); const publicKey = getX25519PublicKey(privateKey); @@ -86,7 +90,7 @@ export const Security = (): JSX.Element => { setPrivateKeyValidationText, ); - setDialogOpen(false); + setPrivateKeyDialogOpen(false); }; const privateKeyInputChangeEvent = ( @@ -149,7 +153,18 @@ export const Security = (): JSX.Element => { inputChange: privateKeyInputChangeEvent, selectChange: privateKeySelectChangeEvent, hide: !privateKeyVisible, - buttonClick: privateKeyClickEvent, + actionButtons: [ + { + text: "Generate", + onClick: privateKeyClickEvent, + variant: "success", + }, + { + text: "Backup Key", + onClick: pkiBackupClickEvent, + variant: "subtle", + }, + ], properties: { value: privateKey, action: { @@ -228,8 +243,8 @@ export const Security = (): JSX.Element => { ]} /> setDialogOpen(false)} + open={privateKeyDialogOpen} + onOpenChange={() => setPrivateKeyDialogOpen(false)} onSubmit={() => pkiRegenerate()} /> diff --git a/src/components/UI/Button.tsx b/src/components/UI/Button.tsx index 39418551..e1b2d704 100644 --- a/src/components/UI/Button.tsx +++ b/src/components/UI/Button.tsx @@ -35,9 +35,11 @@ const buttonVariants = cva( }, ); +export type ButtonVariant = VariantProps["variant"]; + export interface ButtonProps extends React.ButtonHTMLAttributes, - VariantProps {} + VariantProps { } const Button = React.forwardRef( ({ className, variant, size, ...props }, ref) => { diff --git a/src/components/UI/Generator.tsx b/src/components/UI/Generator.tsx index cb8fea26..c983b8ef 100644 --- a/src/components/UI/Generator.tsx +++ b/src/components/UI/Generator.tsx @@ -1,6 +1,6 @@ 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 { Select, @@ -16,11 +16,15 @@ export interface GeneratorProps extends React.BaseHTMLAttributes { devicePSKBitCount?: number; value: string; variant: "default" | "invalid"; - buttonText?: string; + actionButtons: { + text: string; + onClick: React.MouseEventHandler; + variant: ButtonVariant; + className?: string; + }[]; bits?: { text: string; value: string; key: string }[]; selectChange: (event: string) => void; inputChange: (event: React.ChangeEvent) => void; - buttonClick: React.MouseEventHandler; action?: { icon: LucideIcon; onClick: () => void; @@ -35,7 +39,7 @@ const Generator = React.forwardRef( devicePSKBitCount, variant, value, - buttonText, + actionButtons, bits = [ { text: "256 bit", value: "32", key: "bit256" }, { text: "128 bit", value: "16", key: "bit128" }, @@ -43,7 +47,6 @@ const Generator = React.forwardRef( ], selectChange, inputChange, - buttonClick, action, disabled, ...props @@ -93,15 +96,21 @@ const Generator = React.forwardRef( ))} - +
+ {actionButtons?.map(({ text, onClick, variant, className }) => ( + + ))} +
); }, diff --git a/src/core/stores/deviceStore.ts b/src/core/stores/deviceStore.ts index a716a85b..bd407611 100644 --- a/src/core/stores/deviceStore.ts +++ b/src/core/stores/deviceStore.ts @@ -25,7 +25,8 @@ export type DialogVariant = | "shutdown" | "reboot" | "deviceName" - | "nodeRemoval"; + | "nodeRemoval" + | "pkiBackup"; export interface Device { id: number; @@ -60,6 +61,7 @@ export interface Device { reboot: boolean; deviceName: boolean; nodeRemoval: boolean; + pkiBackup: boolean; }; setStatus: (status: Types.DeviceStatusEnum) => void; @@ -142,6 +144,7 @@ export const useDeviceStore = create((set, get) => ({ reboot: false, deviceName: false, nodeRemoval: false, + pkiBackup: false, }, pendingSettingsChanges: false, messageDraft: "", From 7cd03c6a527140912a6dfc05815feaf9fa16a687 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 3 Jan 2025 21:40:56 -0500 Subject: [PATCH 2/3] feat: Add key backup reminder. Refactor Toast component to handle dark mode better --- package.json | 2 + pnpm-lock.yaml | 214 +++++++++++++--------- src/App.tsx | 4 +- src/components/Dialog/DialogManager.tsx | 2 +- src/components/Dialog/PKIBackupDialog.tsx | 13 +- src/components/KeyBackupReminder.tsx | 21 +++ src/components/Toaster.tsx | 17 +- src/components/UI/Toast.tsx | 70 +++---- src/core/hooks/useCookie.ts | 35 ++++ src/core/hooks/useKeyBackupReminder.tsx | 85 +++++++++ 10 files changed, 326 insertions(+), 137 deletions(-) create mode 100644 src/components/KeyBackupReminder.tsx create mode 100644 src/core/hooks/useCookie.ts create mode 100644 src/core/hooks/useKeyBackupReminder.tsx diff --git a/package.json b/package.json index 05221d9b..84a49432 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "cmdk": "^1.0.0", "crypto-random-string": "^5.0.0", "immer": "^10.1.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.363.0", "mapbox-gl": "^3.6.0", "maplibre-gl": "4.1.2", @@ -70,6 +71,7 @@ "@rsbuild/core": "^1.0.10", "@rsbuild/plugin-react": "^1.0.3", "@types/chrome": "^0.0.263", + "@types/js-cookie": "^3.0.6", "@types/node": "^20.14.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9edbfd5..79d6d5ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: immer: specifier: ^10.1.1 version: 10.1.1 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 lucide-react: specifier: ^0.363.0 version: 0.363.0(react@18.3.1) @@ -127,7 +130,7 @@ importers: version: 3.0.6(react@18.3.1) vite-plugin-node-polyfills: specifier: ^0.22.0 - version: 0.22.0(rollup@4.24.0)(vite@5.3.6(@types/node@20.14.9)) + version: 0.22.0(rollup@4.29.1)(vite@5.3.6(@types/node@20.14.9)) zustand: specifier: 4.5.2 version: 4.5.2(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1) @@ -147,6 +150,9 @@ importers: '@types/chrome': specifier: ^0.0.263 version: 0.0.263 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^20.14.9 version: 20.14.9 @@ -173,7 +179,7 @@ importers: version: 8.4.38 rollup-plugin-visualizer: specifier: ^5.12.0 - version: 5.12.0(rollup@4.24.0) + version: 5.12.0(rollup@4.29.1) tailwindcss: specifier: ^3.4.4 version: 3.4.4 @@ -1165,83 +1171,98 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.24.0': - resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==} + '@rollup/rollup-android-arm-eabi@4.29.1': + resolution: {integrity: sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.24.0': - resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==} + '@rollup/rollup-android-arm64@4.29.1': + resolution: {integrity: sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.24.0': - resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==} + '@rollup/rollup-darwin-arm64@4.29.1': + resolution: {integrity: sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.24.0': - resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==} + '@rollup/rollup-darwin-x64@4.29.1': + resolution: {integrity: sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==} cpu: [x64] os: [darwin] - '@rollup/rollup-linux-arm-gnueabihf@4.24.0': - resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==} + '@rollup/rollup-freebsd-arm64@4.29.1': + resolution: {integrity: sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.29.1': + resolution: {integrity: sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.29.1': + resolution: {integrity: sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.24.0': - resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==} + '@rollup/rollup-linux-arm-musleabihf@4.29.1': + resolution: {integrity: sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.24.0': - resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==} + '@rollup/rollup-linux-arm64-gnu@4.29.1': + resolution: {integrity: sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.24.0': - resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==} + '@rollup/rollup-linux-arm64-musl@4.29.1': + resolution: {integrity: sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': - resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==} + '@rollup/rollup-linux-loongarch64-gnu@4.29.1': + resolution: {integrity: sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.29.1': + resolution: {integrity: sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.24.0': - resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==} + '@rollup/rollup-linux-riscv64-gnu@4.29.1': + resolution: {integrity: sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.24.0': - resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==} + '@rollup/rollup-linux-s390x-gnu@4.29.1': + resolution: {integrity: sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.24.0': - resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} + '@rollup/rollup-linux-x64-gnu@4.29.1': + resolution: {integrity: sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.24.0': - resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==} + '@rollup/rollup-linux-x64-musl@4.29.1': + resolution: {integrity: sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.24.0': - resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==} + '@rollup/rollup-win32-arm64-msvc@4.29.1': + resolution: {integrity: sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.24.0': - resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==} + '@rollup/rollup-win32-ia32-msvc@4.29.1': + resolution: {integrity: sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.24.0': - resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==} + '@rollup/rollup-win32-x64-msvc@4.29.1': + resolution: {integrity: sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==} cpu: [x64] os: [win32] @@ -1690,6 +1711,9 @@ packages: '@types/har-format@1.2.15': resolution: {integrity: sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==} + '@types/js-cookie@3.0.6': + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + '@types/mapbox-gl@3.1.0': resolution: {integrity: sha512-hI6cQDjw1bkJw7MC/eHMqq5TWUamLwsujnUUeiIX2KDRjxRNSYMjnHz07+LATz9I9XIsKumOtUz4gRYnZOJ/FA==} @@ -1864,8 +1888,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001638: - resolution: {integrity: sha512-5SuJUJ7cZnhPpeLHaH0c/HPAnAHZvS6ElWyHK9GSIbVOQABLzowiI2pjmpvZ1WEbkyz46iFd4UXlOHR5SqgfMQ==} + caniuse-lite@1.0.30001690: + resolution: {integrity: sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==} cheap-ruler@4.0.0: resolution: {integrity: sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==} @@ -2417,6 +2441,10 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + js-sha3@0.8.0: resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} @@ -2923,8 +2951,8 @@ packages: rollup: optional: true - rollup@4.24.0: - resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} + rollup@4.29.1: + resolution: {integrity: sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4258,68 +4286,77 @@ snapshots: '@radix-ui/rect@1.1.0': {} - '@rollup/plugin-inject@5.0.5(rollup@4.24.0)': + '@rollup/plugin-inject@5.0.5(rollup@4.29.1)': dependencies: - '@rollup/pluginutils': 5.1.0(rollup@4.24.0) + '@rollup/pluginutils': 5.1.0(rollup@4.29.1) estree-walker: 2.0.2 magic-string: 0.30.11 optionalDependencies: - rollup: 4.24.0 + rollup: 4.29.1 - '@rollup/pluginutils@5.1.0(rollup@4.24.0)': + '@rollup/pluginutils@5.1.0(rollup@4.29.1)': dependencies: '@types/estree': 1.0.5 estree-walker: 2.0.2 picomatch: 2.3.1 optionalDependencies: - rollup: 4.24.0 + rollup: 4.29.1 - '@rollup/rollup-android-arm-eabi@4.24.0': + '@rollup/rollup-android-arm-eabi@4.29.1': optional: true - '@rollup/rollup-android-arm64@4.24.0': + '@rollup/rollup-android-arm64@4.29.1': optional: true - '@rollup/rollup-darwin-arm64@4.24.0': + '@rollup/rollup-darwin-arm64@4.29.1': optional: true - '@rollup/rollup-darwin-x64@4.24.0': + '@rollup/rollup-darwin-x64@4.29.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.24.0': + '@rollup/rollup-freebsd-arm64@4.29.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.24.0': + '@rollup/rollup-freebsd-x64@4.29.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.24.0': + '@rollup/rollup-linux-arm-gnueabihf@4.29.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.24.0': + '@rollup/rollup-linux-arm-musleabihf@4.29.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': + '@rollup/rollup-linux-arm64-gnu@4.29.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.24.0': + '@rollup/rollup-linux-arm64-musl@4.29.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.24.0': + '@rollup/rollup-linux-loongarch64-gnu@4.29.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.24.0': + '@rollup/rollup-linux-powerpc64le-gnu@4.29.1': optional: true - '@rollup/rollup-linux-x64-musl@4.24.0': + '@rollup/rollup-linux-riscv64-gnu@4.29.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.24.0': + '@rollup/rollup-linux-s390x-gnu@4.29.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.24.0': + '@rollup/rollup-linux-x64-gnu@4.29.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.24.0': + '@rollup/rollup-linux-x64-musl@4.29.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.29.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.29.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.29.1': optional: true '@rsbuild/core@1.0.10': @@ -4381,7 +4418,7 @@ snapshots: '@module-federation/runtime-tools': 0.5.1 '@rspack/binding': 1.0.8 '@rspack/lite-tapable': 1.0.1 - caniuse-lite: 1.0.30001638 + caniuse-lite: 1.0.30001690 optionalDependencies: '@swc/helpers': 0.5.13 @@ -5255,6 +5292,8 @@ snapshots: '@types/har-format@1.2.15': {} + '@types/js-cookie@3.0.6': {} + '@types/mapbox-gl@3.1.0': dependencies: '@types/geojson': 7946.0.14 @@ -5343,7 +5382,7 @@ snapshots: autoprefixer@10.4.19(postcss@8.4.38): dependencies: browserslist: 4.23.1 - caniuse-lite: 1.0.30001638 + caniuse-lite: 1.0.30001690 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.1 @@ -5424,7 +5463,7 @@ snapshots: browserslist@4.23.1: dependencies: - caniuse-lite: 1.0.30001638 + caniuse-lite: 1.0.30001690 electron-to-chromium: 1.4.812 node-releases: 2.0.14 update-browserslist-db: 1.0.16(browserslist@4.23.1) @@ -5459,7 +5498,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001638: {} + caniuse-lite@1.0.30001690: {} cheap-ruler@4.0.0: {} @@ -6075,6 +6114,8 @@ snapshots: jiti@1.21.6: {} + js-cookie@3.0.5: {} + js-sha3@0.8.0: {} js-tokens@4.0.0: {} @@ -6620,35 +6661,38 @@ snapshots: robust-predicates@3.0.2: {} - rollup-plugin-visualizer@5.12.0(rollup@4.24.0): + rollup-plugin-visualizer@5.12.0(rollup@4.29.1): dependencies: open: 8.4.2 picomatch: 2.3.1 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.24.0 + rollup: 4.29.1 - rollup@4.24.0: + rollup@4.29.1: dependencies: '@types/estree': 1.0.6 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.24.0 - '@rollup/rollup-android-arm64': 4.24.0 - '@rollup/rollup-darwin-arm64': 4.24.0 - '@rollup/rollup-darwin-x64': 4.24.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.24.0 - '@rollup/rollup-linux-arm-musleabihf': 4.24.0 - '@rollup/rollup-linux-arm64-gnu': 4.24.0 - '@rollup/rollup-linux-arm64-musl': 4.24.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0 - '@rollup/rollup-linux-riscv64-gnu': 4.24.0 - '@rollup/rollup-linux-s390x-gnu': 4.24.0 - '@rollup/rollup-linux-x64-gnu': 4.24.0 - '@rollup/rollup-linux-x64-musl': 4.24.0 - '@rollup/rollup-win32-arm64-msvc': 4.24.0 - '@rollup/rollup-win32-ia32-msvc': 4.24.0 - '@rollup/rollup-win32-x64-msvc': 4.24.0 + '@rollup/rollup-android-arm-eabi': 4.29.1 + '@rollup/rollup-android-arm64': 4.29.1 + '@rollup/rollup-darwin-arm64': 4.29.1 + '@rollup/rollup-darwin-x64': 4.29.1 + '@rollup/rollup-freebsd-arm64': 4.29.1 + '@rollup/rollup-freebsd-x64': 4.29.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.29.1 + '@rollup/rollup-linux-arm-musleabihf': 4.29.1 + '@rollup/rollup-linux-arm64-gnu': 4.29.1 + '@rollup/rollup-linux-arm64-musl': 4.29.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.29.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.29.1 + '@rollup/rollup-linux-riscv64-gnu': 4.29.1 + '@rollup/rollup-linux-s390x-gnu': 4.29.1 + '@rollup/rollup-linux-x64-gnu': 4.29.1 + '@rollup/rollup-linux-x64-musl': 4.29.1 + '@rollup/rollup-win32-arm64-msvc': 4.29.1 + '@rollup/rollup-win32-ia32-msvc': 4.29.1 + '@rollup/rollup-win32-x64-msvc': 4.29.1 fsevents: 2.3.3 run-parallel@1.2.0: @@ -6986,9 +7030,9 @@ snapshots: validator@13.12.0: {} - vite-plugin-node-polyfills@0.22.0(rollup@4.24.0)(vite@5.3.6(@types/node@20.14.9)): + vite-plugin-node-polyfills@0.22.0(rollup@4.29.1)(vite@5.3.6(@types/node@20.14.9)): dependencies: - '@rollup/plugin-inject': 5.0.5(rollup@4.24.0) + '@rollup/plugin-inject': 5.0.5(rollup@4.29.1) node-stdlib-browser: 1.2.0 vite: 5.3.6(@types/node@20.14.9) transitivePeerDependencies: @@ -6998,7 +7042,7 @@ snapshots: dependencies: esbuild: 0.21.5 postcss: 8.4.49 - rollup: 4.24.0 + rollup: 4.29.1 optionalDependencies: '@types/node': 20.14.9 fsevents: 2.3.3 diff --git a/src/App.tsx b/src/App.tsx index c936bc9c..8c15dc97 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; import { PageRouter } from "@app/PageRouter.tsx"; import { CommandPalette } from "@components/CommandPalette.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 { Toaster } from "@components/Toaster.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 { Dashboard } from "@pages/Dashboard/index.tsx"; import { MapProvider } from "react-map-gl"; +import { KeyBackupReminder } from "@components/KeyBackupReminder"; export const App = (): JSX.Element => { const { getDevice } = useDeviceStore(); @@ -37,6 +38,7 @@ export const App = (): JSX.Element => { {device ? (
+
diff --git a/src/components/Dialog/DialogManager.tsx b/src/components/Dialog/DialogManager.tsx index 762bd5a7..afebdf5d 100644 --- a/src/components/Dialog/DialogManager.tsx +++ b/src/components/Dialog/DialogManager.tsx @@ -5,7 +5,7 @@ import { QRDialog } from "@components/Dialog/QRDialog.tsx"; import { RebootDialog } from "@components/Dialog/RebootDialog.tsx"; import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; -import { PkiBackupDialog } from "./PKIBackupDialog"; +import { PkiBackupDialog } from "@components/Dialog/PKIBackupDialog"; export const DialogManager = (): JSX.Element => { const { channels, config, dialog, setDialogOpen } = useDevice(); diff --git a/src/components/Dialog/PKIBackupDialog.tsx b/src/components/Dialog/PKIBackupDialog.tsx index 3737236a..4b482bf7 100644 --- a/src/components/Dialog/PKIBackupDialog.tsx +++ b/src/components/Dialog/PKIBackupDialog.tsx @@ -21,7 +21,7 @@ export const PkiBackupDialog = ({ open, onOpenChange, }: PkiBackupDialogProps) => { - const { config } = useDevice(); + const { config, setDialogOpen } = useDevice(); const privateKeyData = config.security?.privateKey // If the private data doesn't exist return null @@ -31,6 +31,10 @@ export const PkiBackupDialog = ({ const getPrivateKey = React.useMemo(() => fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), [config.security?.privateKey]); + const closeDialog = React.useCallback(() => { + setDialogOpen("pkiBackup", false) + }, [setDialogOpen]) + const renderPrintWindow = React.useCallback(() => { const printWindow = window.open("", "_blank"); if (printWindow) { @@ -52,8 +56,10 @@ export const PkiBackupDialog = ({ `); printWindow.document.close(); printWindow.print(); + closeDialog() + } - }, [getPrivateKey]); + }, [getPrivateKey, closeDialog]); const createDownloadKeyFile = React.useCallback(() => { const blob = new Blob([getPrivateKey], { type: "text/plain" }); @@ -65,8 +71,9 @@ export const PkiBackupDialog = ({ document.body.appendChild(link); link.click(); document.body.removeChild(link); + closeDialog() URL.revokeObjectURL(url); - }, [getPrivateKey]); + }, [getPrivateKey, closeDialog]); return ( diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx new file mode 100644 index 00000000..45819bd7 --- /dev/null +++ b/src/components/KeyBackupReminder.tsx @@ -0,0 +1,21 @@ +import { useBackupReminder } from "@app/core/hooks/useKeyBackupReminder"; +import { useDevice } from "@app/core/stores/deviceStore"; + +export const KeyBackupReminder = (): JSX.Element => { + const { setDialogOpen } = useDevice(); + + useBackupReminder({ + suppressDays: 7, + message: "We recommend backing up your key data regularly. Would you like to back up now?", + onAccept: () => setDialogOpen("pkiBackup", true), + cookieOptions: { + secure: true, + sameSite: 'strict' + } + }); + + return ( + <> + + ); +}; diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index cfe13a9d..cf322758 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -1,5 +1,3 @@ -import { useToast } from "@core/hooks/useToast.ts"; - import { Toast, ToastClose, @@ -7,7 +5,8 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "@components/UI/Toast.tsx"; +} from "@components/UI/Toast"; +import { useToast } from "@core/hooks/useToast"; export function Toaster() { const { toasts } = useToast(); @@ -15,16 +14,10 @@ export function Toaster() { return ( {toasts.map(({ id, title, description, action, ...props }) => ( - +
- {title && ( - {title} - )} - {description && ( - - {description} - - )} + {title && {title}} + {description && {description}}
{action} diff --git a/src/components/UI/Toast.tsx b/src/components/UI/Toast.tsx index ca8dfc90..d40b294a 100644 --- a/src/components/UI/Toast.tsx +++ b/src/components/UI/Toast.tsx @@ -1,11 +1,11 @@ -import * as ToastPrimitives from "@radix-ui/react-toast"; -import { type VariantProps, cva } from "class-variance-authority"; -import { X } from "lucide-react"; -import * as React from "react"; +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +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< React.ElementRef, @@ -14,35 +14,34 @@ const ToastViewport = React.forwardRef< -)); -ToastViewport.displayName = ToastPrimitives.Viewport.displayName; +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName 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: { variant: { - default: - "bg-white border-slate-200 dark:bg-slate-800 dark:border-slate-700", + default: "border bg-background text-foreground dark:bg-slate-700 dark:border-slate-600 dark:text-slate-50", 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: { variant: "default", }, - }, -); + } +) const Toast = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & - VariantProps + VariantProps >(({ className, variant, ...props }, ref) => { return ( - ); -}); -Toast.displayName = ToastPrimitives.Root.displayName; + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName const ToastAction = React.forwardRef< React.ElementRef, @@ -61,13 +60,13 @@ const ToastAction = React.forwardRef< -)); -ToastAction.displayName = ToastPrimitives.Action.displayName; +)) +ToastAction.displayName = ToastPrimitives.Action.displayName const ToastClose = React.forwardRef< React.ElementRef, @@ -76,16 +75,16 @@ const ToastClose = React.forwardRef< -)); -ToastClose.displayName = ToastPrimitives.Close.displayName; +)) +ToastClose.displayName = ToastPrimitives.Close.displayName const ToastTitle = React.forwardRef< React.ElementRef, @@ -96,8 +95,8 @@ const ToastTitle = React.forwardRef< className={cn("text-sm font-semibold", className)} {...props} /> -)); -ToastTitle.displayName = ToastPrimitives.Title.displayName; +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName const ToastDescription = React.forwardRef< React.ElementRef, @@ -108,12 +107,12 @@ const ToastDescription = React.forwardRef< className={cn("text-sm opacity-90", className)} {...props} /> -)); -ToastDescription.displayName = ToastPrimitives.Description.displayName; +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName -type ToastProps = React.ComponentPropsWithoutRef; +type ToastProps = React.ComponentPropsWithoutRef -type ToastActionElement = React.ReactElement; +type ToastActionElement = React.ReactElement export { type ToastProps, @@ -125,4 +124,5 @@ export { ToastDescription, ToastClose, ToastAction, -}; +} + diff --git a/src/core/hooks/useCookie.ts b/src/core/hooks/useCookie.ts new file mode 100644 index 00000000..6a1eb311 --- /dev/null +++ b/src/core/hooks/useCookie.ts @@ -0,0 +1,35 @@ +import React from "react"; +import Cookies, { type CookieAttributes } from "js-cookie"; + +type Cookie = [ + T | undefined, + (value: T, options?: CookieAttributes) => void, + () => void, +]; + +const useCookie = ( + cookieName: string, + initialValue?: T, +): Cookie => { + const [cookieValue, setCookieValue] = React.useState(() => { + const cookie = Cookies.get(cookieName); + return cookie ? (JSON.parse(cookie) as T) : initialValue; + }); + + const setCookie = React.useCallback( + (value: T, options?: CookieAttributes) => { + Cookies.set(cookieName, JSON.stringify(value), options); + setCookieValue(value); + }, + [cookieName], + ); + + const removeCookie = React.useCallback(() => { + Cookies.remove(cookieName); + setCookieValue(undefined); + }, [cookieName]); + + return [cookieValue, setCookie, removeCookie]; +}; + +export default useCookie; diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx new file mode 100644 index 00000000..8ce9e1d8 --- /dev/null +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -0,0 +1,85 @@ +import { useEffect, useCallback } from 'react'; +import { useToast } from './useToast'; +import useCookie from './useCookie'; +import type { CookieAttributes } from 'js-cookie'; +import { Button } from '@app/components/UI/Button'; + +interface UseBackupReminderOptions { + suppressDays?: number; + message?: string; + onAccept?: () => void | Promise; + cookieName?: string; + cookieOptions?: CookieAttributes; +} + +interface ReminderState { + suppressed: boolean; + lastShown: string; +} + +export function useBackupReminder({ + suppressDays = 365, + message = "It's time to back up your key data. Would you like to do this now?", + onAccept = () => { }, + cookieName = "backup_reminder_state", + cookieOptions = {}, +}: UseBackupReminderOptions = {}) { + const { toast } = useToast(); + + const [reminderState, setReminderState, resetReminderState] = useCookie(cookieName); + + const suppressReminder = useCallback(() => { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + suppressDays); + + setReminderState( + { + suppressed: true, + lastShown: new Date().toISOString(), + }, + { + ...cookieOptions, + expires: expiryDate, + } + ); + + }, [setReminderState, suppressDays, cookieOptions]); + + useEffect(() => { + if (!reminderState) { + const { dismiss: dimissToast } = toast({ + title: "Backup Reminder", + description: message, + action: ( +
+ + +
+ ), + }); + } + }, [reminderState]); + + return { + resetReminder: resetReminderState + }; +} \ No newline at end of file From cbabcd4782c3097ce1113008dca8eed4ebc34ce2 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Mon, 6 Jan 2025 16:00:13 -0500 Subject: [PATCH 3/3] feat: added delay to toast appearing to avoid conflicting with messages/node loading. --- src/core/hooks/useKeyBackupReminder.tsx | 62 +++++++++++++------------ 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index 8ce9e1d8..9af1ec70 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -17,6 +17,8 @@ interface ReminderState { lastShown: string; } +const TOAST_DELAY = 10000; + export function useBackupReminder({ suppressDays = 365, message = "It's time to back up your key data. Would you like to do this now?", @@ -47,35 +49,37 @@ export function useBackupReminder({ useEffect(() => { if (!reminderState) { - const { dismiss: dimissToast } = toast({ - title: "Backup Reminder", - description: message, - action: ( -
- - -
- ), - }); + setTimeout(() => { + const { dismiss: dimissToast } = toast({ + title: "Backup Reminder", + description: message, + action: ( +
+ + +
+ ), + }); + }, TOAST_DELAY); } }, [reminderState]);