Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import useVpnAuth from './hooks/useVpnAuth';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?raw';
import { eventHandler } from 'services/sockets/event-handler.service';
import RealtimeService from 'services/sockets/socket.service';
import { DownloadBackupKeysDialog } from 'app/drive/components/DownloadBackupKeysDialog';
import { useDownloadBackupKeys } from 'app/drive/components/DownloadBackupKeysDialog/hooks/useDownloadBackupKeys';
const blob = new Blob([workerUrl], { type: 'application/javascript' });
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob);

Expand All @@ -66,6 +68,7 @@ const App = (props: AppProps): JSX.Element => {

const { isDialogOpen } = useActionDialog();
const isOpen = isDialogOpen(ActionDialog.ModifyStorage);
const { openBackupKeysDialog } = useDownloadBackupKeys(t);
const token = localStorageService.get(LocalStorageItem.UserToken);
const newToken = localStorageService.get(LocalStorageItem.NewToken);
const params = new URLSearchParams(window.location.search);
Expand All @@ -90,6 +93,12 @@ const App = (props: AppProps): JSX.Element => {
i18next.changeLanguage();
}, []);

useEffect(() => {
if (isAuthenticated) {
openBackupKeysDialog();
}
}, [isAuthenticated]);

useEffect(() => {
try {
const realtimeService = RealtimeService.getInstance();
Expand Down Expand Up @@ -229,6 +238,7 @@ const App = (props: AppProps): JSX.Element => {
/>

{isOpen && <ModifyStorageModal />}
{isAuthenticated && <DownloadBackupKeysDialog />}

{isFileViewerOpen && fileViewerItem && (
<FileViewerWrapper file={fileViewerItem} onClose={onCloseFileViewer} showPreview={isFileViewerOpen} />
Expand Down
6 changes: 5 additions & 1 deletion src/app/banners/BannerWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { useEffect, useMemo, useState } from 'react';
import { userSelectors } from 'app/store/slices/user';
import FeaturesBanner from './FeaturesBanner';
import newStorageService from 'app/drive/services/new-storage.service';
import { ActionDialog } from 'app/contexts/dialog-manager/ActionDialogManager.context';
import { useActionDialog } from 'app/contexts/dialog-manager/useActionDialog';

const OFFER_END_DAY = new Date('2026-04-26');
const TIMEOUT = 90000;
Expand All @@ -15,6 +17,8 @@ const BannerWrapper = (): JSX.Element => {
const user = useSelector((state: RootState) => state.user.user) as UserSettings;
const plan = useSelector<RootState, PlanState>((state) => state.plan);
const isNewAccount = useSelector((state: RootState) => userSelectors.hasSignedToday(state));
const { isDialogOpen } = useActionDialog();
const isBackupKeysDialogOpen = isDialogOpen(ActionDialog.DownloadBackupKey);
const bannerManager = useMemo(() => new BannerManager(user, plan, OFFER_END_DAY), [user, plan, isNewAccount]);
const [bannersToShow, setBannersToShow] = useState({ showFreeBanner: false, showSubscriptionBanner: false });
const [showDelayedBanner, setShowDelayedBanner] = useState(false);
Expand Down Expand Up @@ -50,7 +54,7 @@ const BannerWrapper = (): JSX.Element => {

return (
<>
{bannersToShow.showFreeBanner && showDelayedBanner && (
{bannersToShow.showFreeBanner && showDelayedBanner && !isBackupKeysDialogOpen && (
<FeaturesBanner onClose={() => onCloseBanner('showFreeBanner')} showBanner />
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum ActionDialog {
NameCollision = 'name-collision',
ModifyStorage = 'modify-storage',
CryptoPayment = 'crypto-payment',
DownloadBackupKey = 'download-backup-key',
}

interface ActionDialogState {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { renderHook, act } from '@testing-library/react';
import { describe, expect, vi, beforeEach, test } from 'vitest';
import { FOURTEEN_DAYS, useDownloadBackupKeys } from './useDownloadBackupKeys';
import { handleExportBackupKey } from 'utils';
import { localStorageService } from 'services';

const mockOpenDialog = vi.fn();
const mockCloseDialog = vi.fn();
const mockIsDialogOpen = vi.fn();

vi.mock('app/contexts/dialog-manager/useActionDialog', () => ({
useActionDialog: () => ({
openDialog: mockOpenDialog,
closeDialog: mockCloseDialog,
isDialogOpen: mockIsDialogOpen,
}),
}));

vi.mock('utils', () => ({
handleExportBackupKey: vi.fn(),
generateCaptchaToken: vi.fn().mockResolvedValue('mocked-captcha-token'),
}));

const translate = vi.fn((key: string) => key);

describe('Download Backup Keys - Custom hook', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsDialogOpen.mockReturnValue(false);
});

test('When the user has never seen the dialog, then the dialog opens', () => {
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: null });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.openBackupKeysDialog());

expect(mockOpenDialog).toHaveBeenCalledOnce();
});

test('When the user already saved the backup key, then the dialog does not open', () => {
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: true, remindMeLater: null });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.openBackupKeysDialog());

expect(mockOpenDialog).not.toHaveBeenCalled();
});

describe('Remind me later', () => {
test('When the user clicked remind me later less than 14 days ago, then the dialog does not open', () => {
const recentDate = new Date(Date.now() - FOURTEEN_DAYS + 1000).toISOString();
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: recentDate });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.openBackupKeysDialog());

expect(mockOpenDialog).not.toHaveBeenCalled();
});

test('When the user clicked remind me later more than 14 days ago, then the dialog opens again', () => {
const expiredDate = new Date(Date.now() - FOURTEEN_DAYS - 1000).toISOString();
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: expiredDate });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.openBackupKeysDialog());

expect(mockOpenDialog).toHaveBeenCalledOnce();
});

test('When the user clicks remind me later, then the current date is saved and the dialog closes', () => {
const backupRemindLaterSpy = vi.spyOn(localStorageService, 'setBackupKeysRemindLater');
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: null });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.onRemindMeLaterButtonClicked());

expect(backupRemindLaterSpy).toHaveBeenCalledOnce();
expect(mockCloseDialog).toHaveBeenCalledOnce();
});
});

describe('Download backup key saved', () => {
test('When the user confirms the backup key is saved, then the saved flag is persisted and the dialog closes', () => {
const backupSavedSpy = vi.spyOn(localStorageService, 'setBackupKeysAcknowledged');
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: null });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.onBackupSavedButtonClicked());

expect(backupSavedSpy).toHaveBeenCalledOnce();
expect(mockCloseDialog).toHaveBeenCalledOnce();
});

test('When the user confirms the backup key is saved and had a remind me later, then the remind me later entry is removed', () => {
const removeItem = vi.spyOn(localStorageService, 'removeItem');
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({
saved: false,
remindMeLater: new Date().toISOString(),
});

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.onBackupSavedButtonClicked());

expect(removeItem).toHaveBeenCalledOnce();
});

test('When the user confirms the backup key is saved without a previous remind me later, then the remind me later entry is not removed', () => {
const removeItem = vi.spyOn(localStorageService, 'removeItem');
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: null });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.onBackupSavedButtonClicked());

expect(removeItem).not.toHaveBeenCalled();
});
});

describe('Downloading keys', () => {
test('When the user clicks the download button, then the key download starts', () => {
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: null });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.onDownloadBackupKeysButtonClicked());

expect(handleExportBackupKey).toHaveBeenCalledWith(translate);
});

test('When the user clicks the download button, then the downloaded state is marked as true', () => {
vi.spyOn(localStorageService, 'getBackupKeys').mockReturnValue({ saved: false, remindMeLater: null });

const { result } = renderHook(() => useDownloadBackupKeys(translate));
act(() => result.current.onDownloadBackupKeysButtonClicked());

expect(result.current.isDownloadedKeys).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ActionDialog } from 'app/contexts/dialog-manager/ActionDialogManager.context';
import { useActionDialog } from 'app/contexts/dialog-manager/useActionDialog';
import { Translate } from 'app/i18n/types';
import notificationsService, { ToastType } from 'app/notifications/services/notifications.service';
import { useState } from 'react';
import { localStorageService, STORAGE_KEYS } from 'services';
import { handleExportBackupKey } from 'utils';

export const FOURTEEN_DAYS = 14 * 24 * 60 * 60 * 1000;

export const useDownloadBackupKeys = (translate: Translate) => {
const [isDownloadedKeys, setIsDownloadedKeys] = useState(false);
const { isDialogOpen, openDialog, closeDialog } = useActionDialog();
const backupKeysLocalStorage = localStorageService.getBackupKeys();
const isBackupKeysDialogOpen = isDialogOpen(ActionDialog.DownloadBackupKey);

const openBackupKeysDialog = () => {
const remindMeLater = backupKeysLocalStorage.remindMeLater;
const remindTimestamp = remindMeLater ? new Date(remindMeLater).getTime() : 0;

const hasExpired = !remindTimestamp || Date.now() - remindTimestamp >= FOURTEEN_DAYS;

if (backupKeysLocalStorage.saved || !hasExpired) return;

openDialog(ActionDialog.DownloadBackupKey);
};

const onRemindMeLaterButtonClicked = () => {
const now = new Date();
localStorageService.setBackupKeysRemindLater(now.toISOString());
closeDialog(ActionDialog.DownloadBackupKey, { closeAllDialogsFirst: true });
};

const onBackupSavedButtonClicked = () => {
localStorageService.setBackupKeysAcknowledged();
if (backupKeysLocalStorage?.remindMeLater) localStorageService.removeItem(STORAGE_KEYS.BACKUP_KEY.REMIND_LATER_AT);
notificationsService.show({ text: translate('modals.downloadBackupKeys.success'), type: ToastType.Success });
closeDialog(ActionDialog.DownloadBackupKey, { closeAllDialogsFirst: true });
};

const onDownloadBackupKeysButtonClicked = () => {
handleExportBackupKey(translate);
setIsDownloadedKeys(true);
};

return {
isDownloadedKeys,
isBackupKeysDialogOpen,
openBackupKeysDialog,
onRemindMeLaterButtonClicked,
onBackupSavedButtonClicked,
onDownloadBackupKeysButtonClicked,
};
};
63 changes: 63 additions & 0 deletions src/app/drive/components/DownloadBackupKeysDialog/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { BaseDialog, Button } from '@internxt/ui';
import { DownloadSimple, Info } from '@phosphor-icons/react';
import { useTranslationContext } from 'app/i18n/provider/TranslationProvider';
import { useDownloadBackupKeys } from './hooks/useDownloadBackupKeys';

export const DownloadBackupKeysDialog = () => {
const { translate } = useTranslationContext();
const {
isBackupKeysDialogOpen,
isDownloadedKeys,
onDownloadBackupKeysButtonClicked,
onBackupSavedButtonClicked,
onRemindMeLaterButtonClicked,
} = useDownloadBackupKeys(translate);

const onCloseButtonClicked = () => {
if (isDownloadedKeys) onBackupSavedButtonClicked();
else onRemindMeLaterButtonClicked();
};

return (
<BaseDialog
isOpen={isBackupKeysDialogOpen}
title={translate('modals.downloadBackupKeys.title')}
dialogRounded={true}
panelClasses="w-screen max-w-lg"
titleClasses="font-medium text-left"
closeClass="shrink-0 flex items-center justify-center h-10 w-10 hover:bg-black/2 rounded-md focus:bg-black/5"
onClose={onCloseButtonClicked}
weightIcon="light"
dataTest="backup-keys-dialog"
>
<div className="flex flex-col w-full p-5 gap-5">
<p className="text-gray-80">{translate('modals.downloadBackupKeys.description')}</p>

{/* Download backup key */}
<div className="flex flex-col items-center">
<Button variant="secondary" onClick={onDownloadBackupKeysButtonClicked}>
{translate('modals.downloadBackupKeys.download')}
<DownloadSimple size={24} className="ml-2" />
</Button>
</div>

{/* Info */}
<div className="flex flex-row items-center justify-center gap-3">
<Info size={24} className="text-primary" />
<div className="flex flex-col">
<p className="text-gray-60">{translate('modals.downloadBackupKeys.info.text')}</p>
<p className="text-gray-60 font-semibold">{translate('modals.downloadBackupKeys.info.path')}</p>
</div>
</div>
<div className="flex flex-row gap-3 items-center justify-end pt-5">
<Button variant="secondary" onClick={onRemindMeLaterButtonClicked}>
{translate('actions.remindMeLater')}
</Button>
<Button variant="primary" disabled={!isDownloadedKeys} onClick={onBackupSavedButtonClicked}>
{translate('actions.backupKeySaved')}
</Button>
</div>
</div>
</BaseDialog>
);
};
14 changes: 13 additions & 1 deletion src/app/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,16 @@
"itemsSelected": "Elemente ausgewählt"
},
"modals": {
"downloadBackupKeys": {
"title": "Denk daran, deinen Backup-Schlüssel herunterzuladen",
"description": "Dein Backup-Schlüssel wird benötigt, um deine verschlüsselten Daten wiederherzustellen. Internxt speichert ihn nicht — nur du hast ihn.",
"info": {
"text": "Du kannst deinen Backup-Schlüssel jederzeit unter ",
"path": "Einstellungen → Konto → Sicherheit"
},
"download": "Backup-Schlüssel herunterladen",
"success": "Du hast deinen Backup-Schlüssel. Bewahre ihn an einem sicheren Ort auf."
},
"renameItemDialog": {
"title": "Umbenennen",
"label": "Name"
Expand Down Expand Up @@ -1496,7 +1506,9 @@
"continue": "Fortsetzen",
"keepCurrent": "Aktuell behalten",
"logOut": "Abmelden",
"locked": "Gesperrt"
"locked": "Gesperrt",
"remindMeLater": "Später erinnern",
"backupKeySaved": "Ich habe meinen Schlüssel gespeichert"
},
"toastNotification": {
"textCopied": "Text in die Zwischenablage kopiert"
Expand Down
14 changes: 13 additions & 1 deletion src/app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,16 @@
"itemsSelected": "items selected"
},
"modals": {
"downloadBackupKeys": {
"title": "Remember to download your backup key",
"description": "Your backup key is needed to recover your encrypted data. Internxt doesn’t store it — only you have it.",
"info": {
"text": "You can find your backup key anytime in ",
"path": "Settings → Account → Security"
},
"download": "Download backup keys",
"success": "You have your backup key. Make sure to store it somewhere safe."
},
"renameItemDialog": {
"title": "Rename",
"label": "Name"
Expand Down Expand Up @@ -1578,7 +1588,9 @@
"continue": "Continue",
"keepCurrent": "Keep current",
"logOut": "Log out",
"locked": "Locked"
"locked": "Locked",
"remindMeLater": "Remind me later",
"backupKeySaved": "I've saved my key"
},
"toastNotification": {
"textCopied": "Text copied to clipboard"
Expand Down
Loading
Loading