feat: Add key backup reminder. Refactor Toast component to handle dark mode better
This commit is contained in:
+3
-1
@@ -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 ? (
|
||||
<div className="flex h-screen">
|
||||
<DialogManager />
|
||||
<KeyBackupReminder />
|
||||
<CommandPalette />
|
||||
<PageRouter />
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<Toast key={id} {...props} className="flex flex-col gap-4">
|
||||
<div className="grid gap-1">
|
||||
{title && (
|
||||
<ToastTitle className="dark:text-white">{title}</ToastTitle>
|
||||
)}
|
||||
{description && (
|
||||
<ToastDescription className="dark:text-white-400">
|
||||
{description}
|
||||
</ToastDescription>
|
||||
)}
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
|
||||
+35
-35
@@ -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<typeof ToastPrimitives.Viewport>,
|
||||
@@ -14,35 +14,34 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
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]",
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
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<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
@@ -50,9 +49,9 @@ const Toast = React.forwardRef<
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
@@ -61,13 +60,13 @@ const ToastAction = React.forwardRef<
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
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",
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
@@ -76,16 +75,16 @@ const ToastClose = React.forwardRef<
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
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",
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
@@ -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<typeof ToastPrimitives.Description>,
|
||||
@@ -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<typeof Toast>;
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
@@ -125,4 +124,5 @@ export {
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import Cookies, { type CookieAttributes } from "js-cookie";
|
||||
|
||||
type Cookie<T> = [
|
||||
T | undefined,
|
||||
(value: T, options?: CookieAttributes) => void,
|
||||
() => void,
|
||||
];
|
||||
|
||||
const useCookie = <T>(
|
||||
cookieName: string,
|
||||
initialValue?: T,
|
||||
): Cookie<T> => {
|
||||
const [cookieValue, setCookieValue] = React.useState<T | undefined>(() => {
|
||||
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;
|
||||
@@ -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<void>;
|
||||
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<ReminderState>(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: (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={"default"}
|
||||
onClick={async () => {
|
||||
await onAccept();
|
||||
dimissToast()
|
||||
suppressReminder();
|
||||
}}
|
||||
>
|
||||
Back up now
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
dimissToast();
|
||||
suppressReminder();
|
||||
}}
|
||||
>
|
||||
Remind me later
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [reminderState]);
|
||||
|
||||
return {
|
||||
resetReminder: resetReminderState
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user