From 3cb4e22411d107277e713e7ac7af5eb2ee87d47d Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 21 Jan 2026 20:57:36 +0100 Subject: [PATCH] Add shared logout flow with dirty check to all logout buttons (#1473) --- .../components/Dialogs/LogoutConfirmModal.tsx | 137 ++++++++++++++++++ .../entrypoints/popup/pages/auth/Unlock.tsx | 23 ++- .../entrypoints/popup/pages/auth/Upgrade.tsx | 23 ++- .../popup/pages/settings/Settings.tsx | 95 ++---------- apps/mobile-app/app/upgrade.tsx | 14 +- 5 files changed, 196 insertions(+), 96 deletions(-) create mode 100644 apps/browser-extension/src/entrypoints/popup/components/Dialogs/LogoutConfirmModal.tsx diff --git a/apps/browser-extension/src/entrypoints/popup/components/Dialogs/LogoutConfirmModal.tsx b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/LogoutConfirmModal.tsx new file mode 100644 index 000000000..e9960980c --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/LogoutConfirmModal.tsx @@ -0,0 +1,137 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { sendMessage } from 'webext-bridge/popup'; + +import ModalWrapper from '@/entrypoints/popup/components/Dialogs/ModalWrapper'; + +interface ILogoutConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; +} + +/** + * A modal component for logout confirmation that checks for unsynced changes. + * Shows a warning if the vault has unsynced changes that would be lost. + */ +const LogoutConfirmModal: React.FC = ({ + isOpen, + onClose, + onConfirm +}) => { + const { t } = useTranslation(); + const [isDirty, setIsDirty] = useState(null); + + /** + * Check if the vault has unsynced changes when the modal opens. + */ + const checkSyncState = useCallback(async (): Promise => { + try { + const syncState = await sendMessage('GET_SYNC_STATE', {}, 'background') as { + isDirty: boolean; + mutationSequence: number; + serverRevision: number; + }; + setIsDirty(syncState.isDirty); + } catch (error) { + console.error('Failed to check sync state:', error); + // Default to showing the simple logout confirmation on error + setIsDirty(false); + } + }, []); + + /** + * Check sync state when modal opens. + */ + useEffect(() => { + if (isOpen) { + // Reset state when modal opens + setIsDirty(null); + checkSyncState(); + } + }, [isOpen, checkSyncState]); + + // Don't render anything if not open or still loading + if (!isOpen || isDirty === null) { + return null; + } + + // Render dirty logout warning modal + if (isDirty) { + return ( + +
+
+ + + +
+
+

+ {t('logout.unsyncedChangesTitle')} +

+
+
+

+ {t('logout.unsyncedChangesWarning')} +

+
+ + +
+
+ ); + } + + // Render normal logout confirmation modal + return ( + +

+ {t('common.logout')} +

+

+ {t('auth.logoutConfirm')} +

+
+ + +
+
+ ); +}; + +export default LogoutConfirmModal; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx index 7cb46e300..ff66b3205 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx @@ -5,6 +5,7 @@ import { sendMessage } from 'webext-bridge/popup'; import AlertMessage from '@/entrypoints/popup/components/AlertMessage'; import Button from '@/entrypoints/popup/components/Button'; +import LogoutConfirmModal from '@/entrypoints/popup/components/Dialogs/LogoutConfirmModal'; import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; @@ -75,6 +76,9 @@ const Unlock: React.FC = () => { // Mobile unlock state const [showMobileUnlockModal, setShowMobileUnlockModal] = useState(false); + // Logout confirmation state + const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); + /** * Make status call to API which acts as health check. * Updates dbContext.isOffline state and returns the result. @@ -418,9 +422,17 @@ const Unlock: React.FC = () => { }; /** - * Handle logout + * Handle logout click - opens the logout confirmation modal. + */ + const handleLogoutClick = () : void => { + setShowLogoutConfirm(true); + }; + + /** + * Handle logout (after confirmation). */ const handleLogout = () : void => { + setShowLogoutConfirm(false); app.logout(); }; @@ -670,7 +682,7 @@ const Unlock: React.FC = () => { )}
- {t('auth.switchAccounts')} + {t('auth.switchAccounts')}
@@ -689,6 +701,13 @@ const Unlock: React.FC = () => { webApi={webApi} mode="unlock" /> + + {/* Logout Confirmation Modal */} + setShowLogoutConfirm(false)} + onConfirm={handleLogout} + /> ); }; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx index 34ffb4df4..7fa7543b7 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Upgrade.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Button from '@/entrypoints/popup/components/Button'; +import LogoutConfirmModal from '@/entrypoints/popup/components/Dialogs/LogoutConfirmModal'; import Modal from '@/entrypoints/popup/components/Dialogs/Modal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; @@ -34,6 +35,7 @@ const Upgrade: React.FC = () => { const [error, setError] = useState(null); const [showSelfHostedWarning, setShowSelfHostedWarning] = useState(false); const [showVersionInfo, setShowVersionInfo] = useState(false); + const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); const { setIsInitialLoading } = useLoading(); const webApi = useWebApi(); const { executeVaultMutationAsync } = useVaultMutate(); @@ -190,9 +192,17 @@ const Upgrade: React.FC = () => { }; /** - * Handle the logout. + * Handle logout click - opens the logout confirmation modal. */ - const handleLogout = async (): Promise => { + const handleLogoutClick = (): void => { + setShowLogoutConfirm(true); + }; + + /** + * Handle the logout (after confirmation). + */ + const handleLogout = (): void => { + setShowLogoutConfirm(false); logout(); }; @@ -306,7 +316,7 @@ const Upgrade: React.FC = () => { + + {/* Logout Confirmation Modal */} + setShowLogoutConfirm(false)} + onConfirm={handleLogout} + /> ); }; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx b/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx index 26e085078..40af776d1 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/settings/Settings.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { sendMessage } from 'webext-bridge/popup'; +import LogoutConfirmModal from '@/entrypoints/popup/components/Dialogs/LogoutConfirmModal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; import { useApp } from '@/entrypoints/popup/context/AppContext'; @@ -31,8 +32,7 @@ const Settings: React.FC = () => { const { setIsInitialLoading } = useLoading(); const { loadApiUrl, getDisplayUrl } = useApiUrl(); const navigate = useNavigate(); - const [showLogoutConfirm, setShowLogoutConfirm] = React.useState(false); - const [showDirtyLogoutWarning, setShowDirtyLogoutWarning] = React.useState(false); + const [showLogoutConfirm, setShowLogoutConfirm] = useState(false); /** * Open the client tab. @@ -113,23 +113,10 @@ const Settings: React.FC = () => { }; /** - * Handle logout click - checks if vault has unsynced changes first. + * Handle logout click - opens the logout confirmation modal. */ - const handleLogoutClick = async () : Promise => { - // Check if vault has unsynced changes - const syncState = await sendMessage('GET_SYNC_STATE', {}, 'background') as { - isDirty: boolean; - mutationSequence: number; - serverRevision: number; - }; - - if (syncState.isDirty) { - // Show warning about unsynced changes - setShowDirtyLogoutWarning(true); - } else { - // No unsynced changes, show normal logout confirmation - setShowLogoutConfirm(true); - } + const handleLogoutClick = () : void => { + setShowLogoutConfirm(true); }; /** @@ -137,7 +124,6 @@ const Settings: React.FC = () => { */ const handleLogout = async () : Promise => { setShowLogoutConfirm(false); - setShowDirtyLogoutWarning(false); try { await webApi.revokeTokens(); @@ -208,70 +194,13 @@ const Settings: React.FC = () => { return ( <> - {/* Logout Confirmation Modal (No Unsynced Changes) */} - {showLogoutConfirm && ( -
-
-

- {t('common.logout')} -

-

- {t('auth.logoutConfirm')} -

-
- - -
-
-
- )} + {/* Logout Confirmation Modal */} + setShowLogoutConfirm(false)} + onConfirm={handleLogout} + /> - {/* Dirty Logout Warning Modal (Unsynced Changes) */} - {showDirtyLogoutWarning && ( -
-
-
-
- - - -
-
-

- {t('logout.unsyncedChangesTitle')} -

-
-
-

- {t('logout.unsyncedChangesWarning')} -

-
- - -
-
-
- )}

{t('common.settings')}

diff --git a/apps/mobile-app/app/upgrade.tsx b/apps/mobile-app/app/upgrade.tsx index 7256891ca..813020bc4 100644 --- a/apps/mobile-app/app/upgrade.tsx +++ b/apps/mobile-app/app/upgrade.tsx @@ -8,6 +8,7 @@ import type { VaultVersion } from '@/utils/dist/core/vault'; import { VaultSqlGenerator } from '@/utils/dist/core/vault'; import { useColors } from '@/hooks/useColorScheme'; +import { useLogout } from '@/hooks/useLogout'; import { useVaultMutate } from '@/hooks/useVaultMutate'; import { useVaultSync } from '@/hooks/useVaultSync'; @@ -26,7 +27,8 @@ import NativeVaultManager from '@/specs/NativeVaultManager'; * Upgrade screen. */ export default function UpgradeScreen() : React.ReactNode { - const { username, logout } = useApp(); + const { username } = useApp(); + const { logoutUserInitiated } = useLogout(); const webApi = useWebApi(); const dbContext = useDb(); const { sqliteClient } = dbContext; @@ -252,15 +254,11 @@ export default function UpgradeScreen() : React.ReactNode { }; /** - * Handle the logout. + * Handle the logout - uses the shared useLogout hook which + * checks for unsynced changes and shows appropriate confirmation dialog. */ const handleLogout = async () : Promise => { - /* - * Clear any stored tokens or session data - * This will be handled by the auth context - */ - await logout(); - router.replace('/login'); + await logoutUserInitiated(); }; /**