diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index 5ba47429d..c4663f181 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -13,7 +13,6 @@ import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; import { NavigationProvider } from '@/entrypoints/popup/context/NavigationContext'; import AuthSettings from '@/entrypoints/popup/pages/auth/AuthSettings'; import Login from '@/entrypoints/popup/pages/auth/Login'; -import MobileLogin from '@/entrypoints/popup/pages/auth/MobileLogin'; import Unlock from '@/entrypoints/popup/pages/auth/Unlock'; import UnlockSuccess from '@/entrypoints/popup/pages/auth/UnlockSuccess'; import Upgrade from '@/entrypoints/popup/pages/auth/Upgrade'; @@ -179,7 +178,6 @@ const App: React.FC = () => { { path: '/', element: , showBackButton: false }, { path: '/reinitialize', element: , showBackButton: false }, { path: '/login', element: , showBackButton: false, layout: LayoutType.AUTH }, - { path: '/mobile-login', element: , showBackButton: false, layout: LayoutType.AUTH }, { path: '/unlock', element: , showBackButton: false, layout: LayoutType.AUTH }, { path: '/unlock-success', element: , showBackButton: false }, { path: '/upgrade', element: , showBackButton: false }, diff --git a/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx new file mode 100644 index 000000000..b807e5a4d --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/Dialogs/MobileUnlockModal.tsx @@ -0,0 +1,228 @@ +import QRCode from 'qrcode'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { MobileLoginUtility } from '@/entrypoints/popup/utils/MobileLoginUtility'; + +import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; +import type { WebApiService } from '@/utils/WebApiService'; + +interface IMobileUnlockModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (result: MobileLoginResult) => Promise; + webApi: WebApiService; + mode?: 'login' | 'unlock'; +} + +/** + * Modal component for mobile login/unlock via QR code scanning. + */ +const MobileUnlockModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + webApi, + mode = 'login' +}) => { + const { t } = useTranslation(); + const [qrCodeUrl, setQrCodeUrl] = useState(null); + const [error, setError] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(120); // 2 minutes in seconds + const mobileLoginRef = useRef(null); + const countdownIntervalRef = useRef(null); + + // Countdown timer effect + useEffect(() => { + if (qrCodeUrl && timeRemaining > 0 && isOpen) { + countdownIntervalRef.current = setInterval(() => { + setTimeRemaining(prev => { + if (prev <= 1) { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + return 0; + } + return prev - 1; + }); + }, 1000); + + return (): void => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + }; + } + }, [qrCodeUrl, timeRemaining, isOpen]); + + // Initialize mobile login when modal opens + useEffect(() => { + if (!isOpen) { + return; + } + + /** + * Initialize mobile login on modal open. + */ + const initiateMobileLogin = async (): Promise => { + try { + setError(null); + setQrCodeUrl(null); + setTimeRemaining(120); + + // Initialize mobile login utility + if (!mobileLoginRef.current) { + mobileLoginRef.current = new MobileLoginUtility(webApi); + } + + // Initiate mobile login and get QR code data + const requestId = await mobileLoginRef.current.initiate(); + + // Generate QR code with AliasVault prefix for mobile login + const qrData = `aliasvault://mobile-login/${requestId}`; + const qrDataUrl = await QRCode.toDataURL(qrData, { + width: 256, + margin: 2, + }); + + setQrCodeUrl(qrDataUrl); + + // Start polling for response + await mobileLoginRef.current.startPolling( + async (result: MobileLoginResult) => { + try { + // Call success callback (parent handles loading state) + await onSuccess(result); + // Close modal after successful processing + handleClose(); + } catch (err) { + // Show error if success handler fails + setError(err instanceof Error ? err.message : t('common.errors.unknownError')); + } + }, + (errorMessage) => { + setError(errorMessage); + } + ); + } catch (err) { + // Check if this is a 404 error (endpoint doesn't exist - server version too old for this feature) + const errorWithStatus = err as Error & { status?: number }; + if (err instanceof Error && errorWithStatus.status === 404) { + setError(t('common.errors.serverVersionTooOld')); + } else { + setError(err instanceof Error ? err.message : t('common.errors.unknownError')); + } + } + }; + + initiateMobileLogin(); + + // Cleanup on unmount or when modal closes + return (): void => { + if (mobileLoginRef.current) { + mobileLoginRef.current.cleanup(); + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + /** + * Handle modal close. + */ + const handleClose = (): void => { + if (mobileLoginRef.current) { + mobileLoginRef.current.cleanup(); + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + setQrCodeUrl(null); + setError(null); + setTimeRemaining(120); + onClose(); + }; + + /** + * Format time remaining as MM:SS. + */ + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + if (!isOpen) { + return null; + } + + const title = mode === 'unlock' ? t('auth.unlockWithMobile') : t('auth.loginWithMobile'); + const description = t('auth.scanQrCode'); + + return ( + + {/* Backdrop */} + + + {/* Modal */} + + + {/* Close button */} + + {t('common.close')} + + + + + + {/* Content */} + + + {title} + + + {description} + + + {error && ( + + {error} + + )} + + {qrCodeUrl && ( + + + + {formatTime(timeRemaining)} + + + )} + + {!qrCodeUrl && !error && ( + + + + )} + + + {t('common.cancel')} + + + + + + ); +}; + +export default MobileUnlockModal; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx index 3606aa71a..6b3adc3ea 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Login.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Button from '@/entrypoints/popup/components/Button'; +import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; import LoginServerInfo from '@/entrypoints/popup/components/LoginServerInfo'; @@ -21,6 +22,7 @@ import { AppInfo } from '@/utils/AppInfo'; import type { VaultResponse, LoginResponse } from '@/utils/dist/shared/models/webapi'; import EncryptionUtility from '@/utils/EncryptionUtility'; import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; +import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; import { storage } from '#imports'; @@ -47,6 +49,7 @@ const Login: React.FC = () => { const [twoFactorCode, setTwoFactorCode] = useState(''); const [clientUrl, setClientUrl] = useState(null); const [error, setError] = useState(null); + const [showMobileLoginModal, setShowMobileLoginModal] = useState(false); const webApi = useWebApi(); const srpUtil = new SrpUtility(webApi); @@ -272,6 +275,63 @@ const Login: React.FC = () => { } }; + /** + * Handle successful mobile login + */ + const handleMobileLoginSuccess = async (result: MobileLoginResult): Promise => { + showLoading(); + try { + // Clear global message if set + app.clearGlobalMessage(); + + // Fetch vault from server with the new auth token + const vaultResponse = await webApi.authFetch('Vault', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${result.token}`, + }, + }); + + // Store auth tokens and username + await app.setAuthTokens(result.username, result.token, result.refreshToken); + + // Store the encryption key and derivation params + await dbContext.storeEncryptionKey(result.decryptionKey); + await dbContext.storeEncryptionKeyDerivationParams({ + salt: result.salt, + encryptionType: result.encryptionType, + encryptionSettings: result.encryptionSettings, + }); + + // Initialize the database with the vault data + const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey); + + // Check for pending migrations + try { + if (await sqliteClient.hasPendingMigrations()) { + navigate('/upgrade', { replace: true }); + hideLoading(); + setIsInitialLoading(false); + return; + } + } catch (err) { + await app.logout(); + setError(err instanceof Error ? err.message : t('common.errors.unknownError')); + hideLoading(); + return; + } + + // Navigate to reinitialize page + hideLoading(); + setIsInitialLoading(false); + navigate('/reinitialize', { replace: true }); + } catch (err) { + setError(err instanceof Error ? err.message : t('common.errors.unknownError')); + hideLoading(); + throw err; // Re-throw to let modal show error + } + }; + /** * Handle change */ @@ -420,13 +480,13 @@ const Login: React.FC = () => { navigate('/mobile-login')} + onClick={() => setShowMobileLoginModal(true)} className="w-full px-4 py-2 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-600 dark:text-white dark:border-gray-500 dark:hover:bg-gray-500 dark:focus:ring-gray-700 flex items-center justify-center gap-2" > - {t('auth.unlockWithMobile')} + {t('auth.loginWithMobile')} @@ -441,6 +501,15 @@ const Login: React.FC = () => { {t('auth.createVault')} + + {/* Mobile Login Modal */} + setShowMobileLoginModal(false)} + onSuccess={handleMobileLoginSuccess} + webApi={webApi} + mode="login" + /> ); }; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/auth/MobileLogin.tsx b/apps/browser-extension/src/entrypoints/popup/pages/auth/MobileLogin.tsx deleted file mode 100644 index dff77b46f..000000000 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/MobileLogin.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import QRCode from 'qrcode'; -import React, { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; - -import Button from '@/entrypoints/popup/components/Button'; -import { useAuth } from '@/entrypoints/popup/context/AuthContext'; -import { useDb } from '@/entrypoints/popup/context/DbContext'; -import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; -import { useWebApi } from '@/entrypoints/popup/context/WebApiContext'; -import { MobileLoginUtility } from '@/entrypoints/popup/utils/MobileLoginUtility'; - -import type { VaultResponse } from '@/utils/dist/shared/models/webapi'; -import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; - -/** - * Mobile login page - scan QR code with mobile device to login. - */ -const MobileLogin: React.FC = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const webApi = useWebApi(); - const { initializeDatabase, storeEncryptionKey, storeEncryptionKeyDerivationParams } = useDb(); - const { setAuthTokens, clearAuth } = useAuth(); - const { showLoading, hideLoading, setIsInitialLoading } = useLoading(); - - const [qrCodeUrl, setQrCodeUrl] = useState(null); - const [error, setError] = useState(null); - const [timeRemaining, setTimeRemaining] = useState(120); // 2 minutes in seconds - const mobileLoginRef = useRef(null); - const countdownIntervalRef = useRef(null); - - // Countdown timer effect - useEffect(() => { - if (qrCodeUrl && timeRemaining > 0) { - countdownIntervalRef.current = setInterval(() => { - setTimeRemaining(prev => { - if (prev <= 1) { - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current); - } - return 0; - } - return prev - 1; - }); - }, 1000); - - return (): void => { - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current); - } - }; - } - }, [qrCodeUrl, timeRemaining]); - - useEffect(() => { - /** - * Initialize mobile login on component mount. - */ - const initiateMobileLogin = async () : Promise => { - try { - showLoading(); - setError(null); - - // Initialize mobile login utility - if (!mobileLoginRef.current) { - mobileLoginRef.current = new MobileLoginUtility(webApi); - } - - // Initiate mobile login and get QR code data - const requestId = await mobileLoginRef.current.initiate(); - - // Generate QR code with AliasVault prefix for mobile login - const qrData = `aliasvault://mobile-login/${requestId}`; - const qrDataUrl = await QRCode.toDataURL(qrData, { - width: 256, - margin: 2, - }); - - setQrCodeUrl(qrDataUrl); - hideLoading(); - - // Start polling for response - await mobileLoginRef.current.startPolling( - async (result: MobileLoginResult) => { - showLoading(); - try { - // Handle successful authentication - await handleSuccessfulAuth( - result.username, - result.token, - result.refreshToken, - result.decryptionKey, - { - salt: result.salt, - encryptionType: result.encryptionType, - encryptionSettings: result.encryptionSettings, - } - ); - } catch (err) { - setError(err instanceof Error ? err.message : t('common.errors.unknownError')); - hideLoading(); - } - }, - (errorMessage) => { - setError(errorMessage); - hideLoading(); - } - ); - } catch (err) { - hideLoading(); - // Check if this is a 404 error (endpoint doesn't exist - server version too old for this feature) - const errorWithStatus = err as Error & { status?: number }; - // TODO: this check can be removed at a later time when v1.0 is ready and 0.25.0 release when this was introduced has been out for a while. - if (err instanceof Error && errorWithStatus.status === 404) { - // Clear auth and navigate back to login with error message - await clearAuth(t('common.errors.serverVersionTooOld')); - navigate('/login'); - } else { - setError(err instanceof Error ? err.message : t('common.errors.unknownError')); - } - } - }; - - initiateMobileLogin(); - - // Cleanup on unmount - return (): void => { - if (mobileLoginRef.current) { - mobileLoginRef.current.cleanup(); - } - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current); - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - /** - * Handle successful authentication. - */ - const handleSuccessfulAuth = async ( - username: string, - token: string, - refreshToken: string, - decryptionKey: string, - vaultMetadata: { salt: string; encryptionType: string; encryptionSettings: string } - ) : Promise => { - // Fetch vault from server with the new auth token - const vaultResponse = await webApi.authFetch('Vault', { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - }, - }); - - // Store auth tokens and username - await setAuthTokens(username, token, refreshToken); - - // Store the encryption key and derivation params - await storeEncryptionKey(decryptionKey); - await storeEncryptionKeyDerivationParams({ - salt: vaultMetadata.salt, - encryptionType: vaultMetadata.encryptionType, - encryptionSettings: vaultMetadata.encryptionSettings, - }); - - // Initialize the database with the vault data - const sqliteClient = await initializeDatabase(vaultResponse, decryptionKey); - - // Check for pending migrations - try { - if (await sqliteClient.hasPendingMigrations()) { - navigate('/upgrade', { replace: true }); - hideLoading(); - return; - } - } catch (err) { - setError(err instanceof Error ? err.message : t('common.errors.unknownError')); - hideLoading(); - return; - } - - // Navigate to credentials page. - hideLoading(); - setIsInitialLoading(false); - navigate('/credentials', { replace: true }); - }; - - /** - * Handle back button. - */ - const handleBack = () : void => { - if (mobileLoginRef.current) { - mobileLoginRef.current.cleanup(); - } - if (countdownIntervalRef.current) { - clearInterval(countdownIntervalRef.current); - } - navigate('/login'); - }; - - /** - * Format time remaining as MM:SS. - */ - const formatTime = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - return ( - - - {error && ( - - {error} - - )} - - {t('auth.unlockWithMobile')} - - {t('auth.scanQrCode')} - - - {qrCodeUrl && ( - - - - {formatTime(timeRemaining)} - - - )} - - - - {t('common.cancel')} - - - - - ); -}; - -export default MobileLogin; 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 5b5ca3210..54366ed7d 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/auth/Unlock.tsx @@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom'; import AlertMessage from '@/entrypoints/popup/components/AlertMessage'; import Button from '@/entrypoints/popup/components/Button'; +import MobileUnlockModal from '@/entrypoints/popup/components/Dialogs/MobileUnlockModal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIcon, HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; import UsernameAvatar from '@/entrypoints/popup/components/Unlock/UsernameAvatar'; @@ -31,6 +32,7 @@ import { unlockWithPin } from '@/utils/PinUnlockService'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; +import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResult'; import { storage } from '#imports'; @@ -71,6 +73,9 @@ const Unlock: React.FC = () => { const [error, setError] = useState(null); const { showLoading, hideLoading, setIsInitialLoading } = useLoading(); + // Mobile unlock state + const [showMobileUnlockModal, setShowMobileUnlockModal] = useState(false); + /** * Make status call to API which acts as health check. * This runs only once during component mount. @@ -356,6 +361,59 @@ const Unlock: React.FC = () => { app.logout(); }; + /** + * Handle successful mobile unlock + */ + const handleMobileUnlockSuccess = async (result: MobileLoginResult): Promise => { + showLoading(); + try { + // Revoke current tokens before setting new ones (since we're already logged in) + await webApi.revokeTokens(); + + // Set new auth tokens + await authContext.setAuthTokens(result.username, result.token, result.refreshToken); + + // Fetch vault from server with the new auth token + const vaultResponse = await webApi.get('Vault'); + + // Store the encryption key and derivation params + await dbContext.storeEncryptionKey(result.decryptionKey); + await dbContext.storeEncryptionKeyDerivationParams({ + salt: result.salt, + encryptionType: result.encryptionType, + encryptionSettings: result.encryptionSettings, + }); + + // Initialize the database with the vault data + const sqliteClient = await dbContext.initializeDatabase(vaultResponse, result.decryptionKey); + + // Check if there are pending migrations + if (await sqliteClient.hasPendingMigrations()) { + navigate('/upgrade', { replace: true }); + hideLoading(); + return; + } + + // Clear dismiss until + await storage.setItem(VAULT_LOCKED_DISMISS_UNTIL_KEY, 0); + + // Reset PIN failed attempts on successful unlock + await resetFailedAttempts(); + + navigate('/reinitialize', { replace: true }); + } catch (err) { + // Check if it's a version incompatibility error + if (err instanceof VaultVersionIncompatibleError) { + await app.logout(err.message); + } else { + setError(t('common.errors.unknownErrorTryAgain')); + } + console.error('Mobile unlock error:', err); + } finally { + hideLoading(); + } + }; + /** * Switch to password mode */ @@ -528,6 +586,18 @@ const Unlock: React.FC = () => { {t('auth.switchAccounts')} {t('auth.logout')} + + {/* Mobile Unlock Button */} + setShowMobileUnlockModal(true)} + className="w-full max-w-md mt-4 px-4 py-2 text-sm font-medium text-center text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 dark:bg-gray-600 dark:text-white dark:border-gray-500 dark:hover:bg-gray-500 dark:focus:ring-gray-700 flex items-center justify-center gap-2" + > + + + + {t('auth.unlockWithMobile')} + {pinAvailable && ( @@ -535,6 +605,15 @@ const Unlock: React.FC = () => { {t('auth.unlockWithPin')} )} + + {/* Mobile Unlock Modal */} + setShowMobileUnlockModal(false)} + onSuccess={handleMobileUnlockSuccess} + webApi={webApi} + mode="unlock" + /> ); }; diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 768751ad8..45173e56e 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -29,8 +29,9 @@ "connectingTo": "Connecting to", "switchAccounts": "Switch accounts?", "loggedIn": "Logged in", - "unlockWithMobile": "Log in using Mobile App", - "scanQrCode": "Scan this QR code with your AliasVault mobile app to log in.", + "loginWithMobile": "Log in using Mobile App", + "unlockWithMobile": "Unlock using Mobile App", + "scanQrCode": "Scan this QR code with your AliasVault mobile app to log in and unlock your vault.", "errors": { "invalidCode": "Please enter a valid 6-digit authentication code.", "serverError": "Could not reach AliasVault server. Please try again later or contact support if the problem persists.", diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css index ee93dfdc7..e9c96b436 100644 --- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css @@ -1815,6 +1815,11 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } +.bg-yellow-100 { + --tw-bg-opacity: 1; + background-color: rgb(254 249 195 / var(--tw-bg-opacity)); +} + .bg-yellow-50 { --tw-bg-opacity: 1; background-color: rgb(254 252 232 / var(--tw-bg-opacity)); @@ -1825,11 +1830,6 @@ video { background-color: rgb(234 179 8 / var(--tw-bg-opacity)); } -.bg-yellow-100 { - --tw-bg-opacity: 1; - background-color: rgb(254 249 195 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; }
+ {description} +
- {t('auth.scanQrCode')} -