committed by
GitHub
16 changed files with 600 additions and 215 deletions
@ -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,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,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,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, |
||||
|
]); |
||||
|
} |
||||
Loading…
Reference in new issue