Add shared logout flow with dirty check to all logout buttons (#1473)

This commit is contained in:
Leendert de Borst
2026-01-21 20:57:36 +01:00
parent c6dd5406cf
commit 3cb4e22411
5 changed files with 196 additions and 96 deletions

View File

@@ -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<ILogoutConfirmModalProps> = ({
isOpen,
onClose,
onConfirm
}) => {
const { t } = useTranslation();
const [isDirty, setIsDirty] = useState<boolean | null>(null);
/**
* Check if the vault has unsynced changes when the modal opens.
*/
const checkSyncState = useCallback(async (): Promise<void> => {
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 (
<ModalWrapper
isOpen={isOpen}
onClose={onClose}
maxWidth="max-w-sm"
showHeaderBorder={false}
showCloseButton={false}
bodyClassName="p-6"
>
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-amber-500" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('logout.unsyncedChangesTitle')}
</h3>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t('logout.unsyncedChangesWarning')}
</p>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-md transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="flex-1 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md transition-colors"
>
{t('logout.logoutAnyway')}
</button>
</div>
</ModalWrapper>
);
}
// Render normal logout confirmation modal
return (
<ModalWrapper
isOpen={isOpen}
onClose={onClose}
maxWidth="max-w-sm"
showHeaderBorder={false}
showCloseButton={false}
bodyClassName="p-6"
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
{t('common.logout')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t('auth.logoutConfirm')}
</p>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-md transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="flex-1 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md transition-colors"
>
{t('common.logout')}
</button>
</div>
</ModalWrapper>
);
};
export default LogoutConfirmModal;

View File

@@ -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 = () => {
)}
<div className="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
{t('auth.switchAccounts')} <button type="button" onClick={handleLogout} className="text-primary-600 hover:text-primary-700 dark:text-primary-500 dark:hover:text-primary-400 hover:underline font-medium">{t('common.logout')}</button>
{t('auth.switchAccounts')} <button type="button" onClick={handleLogoutClick} className="text-primary-600 hover:text-primary-700 dark:text-primary-500 dark:hover:text-primary-400 hover:underline font-medium">{t('common.logout')}</button>
</div>
</form>
</div>
@@ -689,6 +701,13 @@ const Unlock: React.FC = () => {
webApi={webApi}
mode="unlock"
/>
{/* Logout Confirmation Modal */}
<LogoutConfirmModal
isOpen={showLogoutConfirm}
onClose={() => setShowLogoutConfirm(false)}
onConfirm={handleLogout}
/>
</div>
);
};

View File

@@ -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<string | null>(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<void> => {
const handleLogoutClick = (): void => {
setShowLogoutConfirm(true);
};
/**
* Handle the logout (after confirmation).
*/
const handleLogout = (): void => {
setShowLogoutConfirm(false);
logout();
};
@@ -306,7 +316,7 @@ const Upgrade: React.FC = () => {
</Button>
<button
type="button"
onClick={handleLogout}
onClick={handleLogoutClick}
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium py-2"
disabled={isLoading}
>
@@ -314,6 +324,13 @@ const Upgrade: React.FC = () => {
</button>
</div>
</form>
{/* Logout Confirmation Modal */}
<LogoutConfirmModal
isOpen={showLogoutConfirm}
onClose={() => setShowLogoutConfirm(false)}
onConfirm={handleLogout}
/>
</div>
);
};

View File

@@ -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<void> => {
// 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<void> => {
setShowLogoutConfirm(false);
setShowDirtyLogoutWarning(false);
try {
await webApi.revokeTokens();
@@ -208,70 +194,13 @@ const Settings: React.FC = () => {
return (
<>
{/* Logout Confirmation Modal (No Unsynced Changes) */}
{showLogoutConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-3">
{t('common.logout')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t('auth.logoutConfirm')}
</p>
<div className="flex gap-3">
<button
onClick={() => setShowLogoutConfirm(false)}
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-md transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleLogout}
className="flex-1 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md transition-colors"
>
{t('common.logout')}
</button>
</div>
</div>
</div>
)}
{/* Logout Confirmation Modal */}
<LogoutConfirmModal
isOpen={showLogoutConfirm}
onClose={() => setShowLogoutConfirm(false)}
onConfirm={handleLogout}
/>
{/* Dirty Logout Warning Modal (Unsynced Changes) */}
{showDirtyLogoutWarning && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4">
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-amber-500" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('logout.unsyncedChangesTitle')}
</h3>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t('logout.unsyncedChangesWarning')}
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDirtyLogoutWarning(false)}
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white rounded-md transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleLogout}
className="flex-1 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md transition-colors"
>
{t('logout.logoutAnyway')}
</button>
</div>
</div>
</div>
)}
<div className="space-y-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-gray-900 dark:text-white text-xl">{t('common.settings')}</h2>

View File

@@ -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<void> => {
/*
* Clear any stored tokens or session data
* This will be handled by the auth context
*/
await logout();
router.replace('/login');
await logoutUserInitiated();
};
/**