Add logout event emitter (#1274)

This commit is contained in:
Leendert de Borst
2025-09-26 17:48:41 +02:00
committed by Leendert de Borst
parent 624296da0d
commit 5215a0bdb8
21 changed files with 108 additions and 73 deletions

View File

@@ -1,8 +1,11 @@
import React, { createContext, useContext, useMemo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/entrypoints/popup/context/AuthContext';
import { useWebApi } from '@/entrypoints/popup/context/WebApiContext';
import { logoutEventEmitter } from '@/events/LogoutEventEmitter';
type AppContextType = {
isLoggedIn: boolean;
isInitialized: boolean;
@@ -23,6 +26,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const auth = useAuth();
const webApi = useWebApi();
const [isLoggedIn, setIsLoggedIn] = useState(false);
const { t } = useTranslation();
/**
* Logout the user by revoking tokens and clearing the auth tokens from storage.
@@ -38,13 +42,22 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
* @returns boolean indicating whether the user is logged in.
*/
const initializeAuth = useCallback(async () : Promise<boolean> => {
console.log('initializeAuth');
const isLoggedIn = await auth.initializeAuth();
console.log('isLoggedIn', isLoggedIn);
setIsLoggedIn(isLoggedIn);
return isLoggedIn;
}, [auth]);
/**
* Subscribe to logout events from WebApiService.
*/
useEffect(() => {
const unsubscribe = logoutEventEmitter.subscribe(async (errorKey: string) => {
await logout(t(errorKey));
});
return unsubscribe;
}, [logout, t]);
/**
* Check for tokens in browser local storage on initial load when this context is mounted.
*/

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
import { sendMessage } from 'webext-bridge/popup';
import { useDb } from '@/entrypoints/popup/context/DbContext';

View File

@@ -85,10 +85,6 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch
// Listen on isloggedin state to redirect to login page if not logged in
useEffect(() => {
console.log('authInitialized', authInitialized);
console.log('dbInitialized', dbInitialized);
console.log('isFullyInitialized', isFullyInitialized);
console.log('isLoggedIn', isLoggedIn);
if (isFullyInitialized && !isLoggedIn) {
navigate('/login', { replace: true });
}

View File

@@ -223,7 +223,7 @@ const Login: React.FC = () => {
showLoading();
if (!passwordHashString || !passwordHashBase64 || !loginResponse) {
throw new Error(t('auth.errors.loginDataMissing'));
throw new Error(t('common.errors.unknownError'));
}
// Validate that 2FA code is a 6-digit number

View File

@@ -0,0 +1,38 @@
type LogoutListener = (errorMessage: string) => void | Promise<void>;
/**
* Simple event emitter for logout events to avoid circular dependencies
* between WebApiService and Auth contexts.
*/
class LogoutEventEmitter {
private listeners: Set<LogoutListener> = new Set();
/**
* Subscribe to logout events.
* Returns an unsubscribe function.
*/
public subscribe(listener: LogoutListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/**
* Emit a logout event to all listeners.
*
* @param errorKey - The translation key of the error message to emit.
*/
public emit(errorTranslationKey: string): void {
this.listeners.forEach(listener => {
try {
listener(errorTranslationKey);
} catch (error) {
console.error('Error in logout listener:', error);
}
});
}
}
// Export singleton instance
export const logoutEventEmitter = new LogoutEventEmitter();

View File

@@ -37,8 +37,7 @@
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
"networkError": "Network error. Please check your connection and try again."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "Beim Prüfen auf ausstehende Migrationen ist ein Fehler aufgetreten.",
"wrongPassword": "Falsches Passwort. Bitte versuche es erneut.",
"accountLocked": "Das Konto wurde wegen zu vieler fehlgeschlagener Anmeldeversuche vorübergehend gesperrt.",
"networkError": "Netzwerkfehler. Bitte überprüfe Deine Verbindung und versuche es erneut.",
"loginDataMissing": "Deine Anmelde-Sitzung ist abgelaufen. Bitte versuche es erneut."
"networkError": "Netzwerkfehler. Bitte überprüfe Deine Verbindung und versuche es erneut."
}
},
"menu": {

View File

@@ -38,7 +38,7 @@
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
"sessionExpired": "Your session has expired. Please log in again."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
"networkError": "Network error. Please check your connection and try again."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "Tapahtui virhe tarkistettaessa odottavia siirtoja.",
"wrongPassword": "Virheellinen salasana. Yritä uudelleen.",
"accountLocked": "Tili on tilapäisesti lukittu liian monen epäonnistuneen yrityksen vuoksi. Yritä myöhemmin uudelleen.",
"networkError": "Verkkovirhe: tarkista yhteytesi ja yritä uudelleen.",
"loginDataMissing": "Kirjautumisistunto on vanhentunut. Yritä uudelleen."
"networkError": "Verkkovirhe: tarkista yhteytesi ja yritä uudelleen."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "Une erreur s'est produite lors de la vérification des migrations en attente.",
"wrongPassword": "Mot de passe incorrect, veuillez réessayer.",
"accountLocked": "Compte temporairement verrouillé en raison d'un trop grand nombre de tentatives échouées.",
"networkError": "Erreur réseau. Vérifiez votre connexion et réessayez.",
"loginDataMissing": "La session a expiré. Veuillez réessayer."
"networkError": "Erreur réseau. Vérifiez votre connexion et réessayez."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "אירעה שגיאה בעת בדיקה לאיתור הסבות ממתינות.",
"wrongPassword": "סיסמה שגויה. נא לנסות שוב.",
"accountLocked": "החשבון נעול זמנית עקב ריבוי ניסיונות כושלים.",
"networkError": "שגיאת רשת. נא לבדוק את החיבור ולנסות שוב.",
"loginDataMissing": "תוקף ההפעלה שלך פג. נא לנסות שוב."
"networkError": "שגיאת רשת. נא לבדוק את החיבור ולנסות שוב."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "Si è verificato un errore nel controllo delle migrazioni pendenti.",
"wrongPassword": "Password non corretta. Riprova nuovamente.",
"accountLocked": "Account temporaneamente bloccato a causa di troppi tentativi falliti.",
"networkError": "Errore di rete: Controlla la tua connessione e riprova.",
"loginDataMissing": "Sessione di accesso scaduta. Effettua nuovamente l'accesso."
"networkError": "Errore di rete: Controlla la tua connessione e riprova."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "Er is een fout opgetreden bij het controleren op updates.",
"wrongPassword": "Onjuist wachtwoord. Probeer het opnieuw.",
"accountLocked": "Account tijdelijk vergrendeld vanwege te veel mislukte pogingen.",
"networkError": "Netwerkfout. Controleer de verbinding en probeer het opnieuw.",
"loginDataMissing": "Sessie verlopen. Probeer het opnieuw."
"networkError": "Netwerkfout. Controleer de verbinding en probeer het opnieuw."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
"networkError": "Network error. Please check your connection and try again."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "Возникла ошибка при проверке ожидающих перемещений.",
"wrongPassword": "Неверный пароль. Пожалуйста, повторите попытку.",
"accountLocked": "Аккаунт временно заблокирован из-за слишком большого числа неудачных попыток.",
"networkError": "Ошибка сети. Пожалуйста, проверьте соединение и повторите еще раз.",
"loginDataMissing": "Время входа истекло. Пожалуйста, повторите попытку."
"networkError": "Ошибка сети. Пожалуйста, проверьте соединение и повторите еще раз."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
"networkError": "Network error. Please check your connection and try again."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "An error occurred while checking for pending migrations.",
"wrongPassword": "Incorrect password. Please try again.",
"accountLocked": "Account temporarily locked due to too many failed attempts.",
"networkError": "Network error. Please check your connection and try again.",
"loginDataMissing": "Login session expired. Please try again."
"networkError": "Network error. Please check your connection and try again."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "Під час перевірки незавершених перенесень сталася помилка.",
"wrongPassword": "Невірний пароль. Будь ласка, спробуйте ще раз.",
"accountLocked": "Обліковий запис тимчасово заблоковано через занадто багато невдалих спроб.",
"networkError": "Помилка мережі. Будь ласка, перевірте з’єднання та спробуйте ще раз.",
"loginDataMissing": "Термін дії сеансу закінчився. Будь ласка, спробуйте ще раз."
"networkError": "Помилка мережі. Будь ласка, перевірте з’єднання та спробуйте ще раз."
}
},
"menu": {

View File

@@ -37,8 +37,7 @@
"migrationError": "检查待处理迁移时发生错误。",
"wrongPassword": "密码不正确。请重试。",
"accountLocked": "由于多次尝试失败,账户已暂时锁定。",
"networkError": "网络错误。请检查你的连接后重试。",
"loginDataMissing": "登录会话已过期。请重试。"
"networkError": "网络错误。请检查你的连接后重试。"
}
},
"menu": {

View File

@@ -1,5 +1,7 @@
import type { StatusResponse } from '@/utils/dist/shared/models/webapi';
import { logoutEventEmitter } from '@/events/LogoutEventEmitter';
import { AppInfo } from "./AppInfo";
import { storage } from '#imports';
@@ -74,7 +76,7 @@ export class WebApiService {
return parseJson ? retryResponse.json() : retryResponse as unknown as T;
} else {
this.logout('Your session has expired. Please login again.');
logoutEventEmitter.emit('auth.errors.sessionExpired');
throw new Error('Session expired');
}
}
@@ -118,41 +120,6 @@ export class WebApiService {
}
}
/**
* Refresh the access token.
*/
private async refreshAccessToken(): Promise<string | null> {
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const response = await this.rawFetch('Auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Ignore-Failure': 'true',
},
body: JSON.stringify({
token: await this.getAccessToken(),
refreshToken: refreshToken,
}),
});
if (!response.ok) {
throw new Error('Failed to refresh token');
}
const tokenResponse: TokenResponse = await response.json();
this.updateTokens(tokenResponse.token, tokenResponse.refreshToken);
return tokenResponse.token;
} catch {
this.logout('Your session has expired. Please login again.');
return null;
}
}
/**
* Issue GET request to the API.
*/
@@ -271,6 +238,41 @@ export class WebApiService {
return null;
}
/**
* Refresh the access token.
*/
private async refreshAccessToken(): Promise<string | null> {
const refreshToken = await this.getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const response = await this.rawFetch('Auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Ignore-Failure': 'true',
},
body: JSON.stringify({
token: await this.getAccessToken(),
refreshToken: refreshToken,
}),
});
if (!response.ok) {
throw new Error('Failed to refresh token');
}
const tokenResponse: TokenResponse = await response.json();
this.updateTokens(tokenResponse.token, tokenResponse.refreshToken);
return tokenResponse.token;
} catch {
logoutEventEmitter.emit('auth.errors.sessionExpired');
return null;
}
}
/**
* Get the current access token from storage.
*/