diff --git a/web-app/src/app/App.tsx b/web-app/src/app/App.tsx
index 9ed54430d..7ec4f7fd8 100644
--- a/web-app/src/app/App.tsx
+++ b/web-app/src/app/App.tsx
@@ -8,13 +8,24 @@ import { RemoteConfigProvider } from './context/RemoteConfigProvider';
import { useDispatch } from 'react-redux';
import { anonymousLogin } from './store/profile-reducer';
import i18n from '../i18n';
-import { Suspense, useEffect } from 'react';
+import { Suspense, useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
+import { app } from '../firebase';
function App(): React.ReactElement {
require('typeface-muli'); // Load font
const dispatch = useDispatch();
+ const [isAppReady, setIsAppReady] = useState(false);
+
useEffect(() => {
+ app.auth().onAuthStateChanged((user) => {
+ if (user != null) {
+ setIsAppReady(true);
+ } else {
+ setIsAppReady(false);
+ dispatch(anonymousLogin());
+ }
+ });
dispatch(anonymousLogin());
}, [dispatch]);
@@ -26,7 +37,7 @@ function App(): React.ReactElement {
-
+ {isAppReady ? : null}
diff --git a/web-app/src/app/components/LogoutConfirmModal.tsx b/web-app/src/app/components/LogoutConfirmModal.tsx
index e134e7b1d..26cc819a0 100644
--- a/web-app/src/app/components/LogoutConfirmModal.tsx
+++ b/web-app/src/app/components/LogoutConfirmModal.tsx
@@ -10,7 +10,7 @@ import {
import React from 'react';
import { useAppDispatch } from '../hooks';
import { logout } from '../store/profile-reducer';
-import { SIGN_IN_TARGET } from '../constants/Navigation';
+import { SIGN_OUT_TARGET } from '../constants/Navigation';
import { useNavigate } from 'react-router-dom';
interface ConfirmModalProps {
@@ -25,7 +25,9 @@ export default function ConfirmModal({
const dispatch = useAppDispatch();
const navigateTo = useNavigate();
const confirmLogout = (): void => {
- dispatch(logout({ redirectScreen: SIGN_IN_TARGET, navigateTo }));
+ dispatch(
+ logout({ redirectScreen: SIGN_OUT_TARGET, navigateTo, propagate: true }),
+ );
setOpenDialog(false);
};
diff --git a/web-app/src/app/constants/Navigation.ts b/web-app/src/app/constants/Navigation.ts
index 9a218aa09..83a0adda9 100644
--- a/web-app/src/app/constants/Navigation.ts
+++ b/web-app/src/app/constants/Navigation.ts
@@ -1,7 +1,7 @@
import type NavigationItem from '../interface/Navigation';
import { type RemoteConfigValues } from '../interface/RemoteConfig';
-export const SIGN_OUT_TARGET = '/sign-out';
+export const SIGN_OUT_TARGET = '/';
export const SIGN_IN_TARGET = '/sign-in';
export const ACCOUNT_TARGET = '/account';
export const POST_REGISTRATION_TARGET = '/verify-email';
diff --git a/web-app/src/app/router/Router.tsx b/web-app/src/app/router/Router.tsx
index 506863293..7beae4113 100644
--- a/web-app/src/app/router/Router.tsx
+++ b/web-app/src/app/router/Router.tsx
@@ -1,5 +1,5 @@
-import React from 'react';
-import { Routes, Route } from 'react-router-dom';
+import React, { useEffect } from 'react';
+import { Routes, Route, useNavigate } from 'react-router-dom';
import SignIn from '../screens/SignIn';
import SignUp from '../screens/SignUp';
import Account from '../screens/Account';
@@ -17,8 +17,45 @@ import TermsAndConditions from '../screens/TermsAndConditions';
import PrivacyPolicy from '../screens/PrivacyPolicy';
import Feed from '../screens/Feed';
import Feeds from '../screens/Feeds';
+import { SIGN_OUT_TARGET } from '../constants/Navigation';
+import {
+ LOGIN_CHANNEL,
+ LOGOUT_CHANNEL,
+ createBroadcastChannel,
+} from '../services/channel-service';
+import { useAppDispatch } from '../hooks';
+import { logout } from '../store/profile-reducer';
export const AppRouter: React.FC = () => {
+ const navigateTo = useNavigate();
+ const dispatch = useAppDispatch();
+
+ /**
+ * Logs out the user and redirects to the sign-out target screen after a logout event is received on the other sessions.
+ */
+ const logoutUserCallback = (): void => {
+ dispatch(
+ logout({ redirectScreen: SIGN_OUT_TARGET, navigateTo, propagate: false }),
+ );
+ };
+
+ /**
+ * Refreshes the page to ensure the user is authenticated after a login event is received on the other sessions.
+ */
+ const loginUserCallback = (): void => {
+ window.location.reload();
+ };
+
+ /**
+ * The channel creation is placed in this component rather than the App.tsx file due to the need of the navigateTo instance.
+ * The navigateTo instance is only available within the scope the Router including its children.
+ * The callback functions are used to handle the logout and login events received from other sessions.
+ */
+ useEffect(() => {
+ createBroadcastChannel(LOGOUT_CHANNEL, logoutUserCallback);
+ createBroadcastChannel(LOGIN_CHANNEL, loginUserCallback);
+ }, []);
+
return (
} />
diff --git a/web-app/src/app/services/channel-service.ts b/web-app/src/app/services/channel-service.ts
new file mode 100644
index 000000000..ff1f35b65
--- /dev/null
+++ b/web-app/src/app/services/channel-service.ts
@@ -0,0 +1,52 @@
+let channels: Map | undefined;
+
+export const LOGOUT_CHANNEL = 'logout-channel';
+export const LOGIN_CHANNEL = 'login-channel';
+
+/**
+ * Creates a new broadcast channel with the specified name and callback. The callback is called when a message is received.
+ * If the channel already exists, the function returns false.
+ * @param channelName name of the channel
+ * @param callback function to be called when a message is received
+ * @returns true if the channel was created, false if the channel already exists
+ * @see broadcastMessage
+ */
+export const createBroadcastChannel = (
+ channelName: string,
+ callback: () => void,
+): boolean => {
+ if (channels === undefined) {
+ channels = new Map();
+ }
+ let channel = channels.get(channelName);
+ if (channel !== undefined) {
+ return false;
+ }
+ channel = new BroadcastChannel(channelName);
+ channel.onmessage = () => {
+ callback();
+ };
+ channels.set(channelName, channel);
+ return true;
+};
+
+/**
+ * Broadcasts a message to all subscribers of the channel. The channel must be created before broadcasting.
+ * If the channel is not found, an error is thrown.
+ * @param channelName name of the channel
+ * @param message to be broadcasted or undefined
+ * @see createDispatchChannel
+ */
+export const broadcastMessage = (
+ channelName: string,
+ message?: string,
+): void => {
+ if (channels === undefined) {
+ throw new Error('No channels created');
+ }
+ const channel = channels.get(channelName);
+ if (channel === undefined) {
+ throw new Error(`Channel ${channelName} not found`);
+ }
+ channel.postMessage(message);
+};
diff --git a/web-app/src/app/store/profile-reducer.ts b/web-app/src/app/store/profile-reducer.ts
index 994300fde..34eec6121 100644
--- a/web-app/src/app/store/profile-reducer.ts
+++ b/web-app/src/app/store/profile-reducer.ts
@@ -43,6 +43,7 @@ const initialState: UserProfileState = {
Registration: null,
ResetPassword: null,
VerifyEmail: null,
+ AnonymousLogin: null,
},
isRefreshingAccessToken: false,
isVerificationEmailSent: false,
@@ -83,6 +84,7 @@ export const userProfileSlice = createSlice({
action: PayloadAction<{
redirectScreen: string;
navigateTo: NavigateFunction;
+ propagate: boolean;
}>,
) => {
state.status = 'login_out';
@@ -93,6 +95,7 @@ export const userProfileSlice = createSlice({
state.status = 'unauthenticated';
state.isSignedInWithProvider = false;
state.isAppRefreshing = false;
+ state.user = undefined;
},
logoutFail: (state) => {
state.status = 'unauthenticated';
@@ -253,9 +256,15 @@ export const userProfileSlice = createSlice({
},
anonymousLogin: (state) => {
state.isAppRefreshing = true;
+ state.errors.AnonymousLogin = null;
},
- anonymousLoginFailed: (state) => {
+ anonymousLoginFailed: (state, action: PayloadAction) => {
state.isAppRefreshing = false;
+ state.errors.AnonymousLogin = action.payload;
+ },
+ anonymousLoginSkipped: (state, action: PayloadAction) => {
+ state.isAppRefreshing = false;
+ state.errors.AnonymousLogin = action.payload;
},
},
});
@@ -293,6 +302,7 @@ export const {
emailVerified,
anonymousLogin,
anonymousLoginFailed,
+ anonymousLoginSkipped,
} = userProfileSlice.actions;
export default userProfileSlice.reducer;
diff --git a/web-app/src/app/store/saga/auth-saga.ts b/web-app/src/app/store/saga/auth-saga.ts
index cc133b2b7..481f1a214 100644
--- a/web-app/src/app/store/saga/auth-saga.ts
+++ b/web-app/src/app/store/saga/auth-saga.ts
@@ -14,6 +14,7 @@ import {
USER_PROFILE_SEND_VERIFICATION_EMAIL,
USER_PROFILE_ANONYMOUS_LOGIN,
type ProfileError,
+ ProfileErrorSource,
} from '../../types';
import 'firebase/compat/auth';
import {
@@ -28,8 +29,8 @@ import {
resetPasswordSuccess,
verifyFail,
verifySuccess,
- anonymousLogin,
anonymousLoginFailed,
+ anonymousLoginSkipped,
} from '../profile-reducer';
import { type NavigateFunction } from 'react-router-dom';
import {
@@ -49,7 +50,12 @@ import {
signInAnonymously,
} from 'firebase/auth';
import { getAppError } from '../../utils/error';
-import { selectIsAuthenticated } from '../profile-selectors';
+import { selectIsAnonymous, selectIsAuthenticated } from '../profile-selectors';
+import {
+ LOGIN_CHANNEL,
+ LOGOUT_CHANNEL,
+ broadcastMessage,
+} from '../../services/channel-service';
function* emailLoginSaga({
payload: { email, password },
@@ -68,22 +74,26 @@ function* emailLoginSaga({
undefined,
);
yield put(loginSuccess(userEnhanced));
+ broadcastMessage(LOGIN_CHANNEL);
} catch (error) {
yield put(loginFail(getAppError(error) as ProfileError));
}
}
function* logoutSaga({
- payload: { redirectScreen, navigateTo },
+ payload: { redirectScreen, navigateTo, propagate },
}: PayloadAction<{
redirectScreen: string;
navigateTo: NavigateFunction;
+ propagate: boolean;
}>): Generator {
try {
+ navigateTo(redirectScreen);
yield app.auth().signOut();
yield put(logoutSuccess());
- yield put(anonymousLogin()); // Use anonymous login to keep the user signed in
- navigateTo(redirectScreen);
+ if (propagate) {
+ broadcastMessage(LOGOUT_CHANNEL);
+ }
} catch (error) {
yield put(loginFail(getAppError(error) as ProfileError));
}
@@ -159,6 +169,7 @@ function* loginWithProviderSaga({
additionalUserInfo,
);
yield put(loginSuccess(userEnhanced));
+ broadcastMessage(LOGIN_CHANNEL);
} catch (error) {
yield put(loginFail(getAppError(error) as ProfileError));
}
@@ -185,7 +196,13 @@ function* anonymousLoginSaga(): Generator {
selectIsAuthenticated,
)) as boolean;
if (isAuthenticated) {
- yield put(anonymousLoginFailed()); // fails silently
+ yield put(
+ anonymousLoginSkipped({
+ code: 'unknown',
+ message: 'User is already authenticated',
+ source: ProfileErrorSource.AnonymousLogin,
+ }),
+ );
return;
}
@@ -194,13 +211,39 @@ function* anonymousLoginSaga(): Generator {
yield call(async () => {
await signInAnonymously(auth);
});
+
+ const hasStateAnonymousSet: boolean = (yield select(
+ selectIsAnonymous,
+ )) as boolean;
+ if (hasStateAnonymousSet) {
+ yield put(
+ anonymousLoginSkipped({
+ code: 'unknown',
+ message: 'User had already login as anonymous before.',
+ source: ProfileErrorSource.AnonymousLogin,
+ }),
+ );
+ return;
+ }
const user = yield call(getUserFromSession);
if (user === null) {
- throw new Error('User not found');
+ anonymousLoginSkipped({
+ code: 'unknown',
+ message: 'User not found',
+ source: ProfileErrorSource.AnonymousLogin,
+ });
+ return;
}
const firebaseUser = app.auth().currentUser;
if (firebaseUser === null) {
- throw new Error('Firebase user not found');
+ yield put(
+ anonymousLoginFailed({
+ code: 'unknown',
+ message: 'User not found',
+ source: ProfileErrorSource.AnonymousLogin,
+ }),
+ );
+ return;
}
const currentUser = {
...user,
@@ -208,7 +251,13 @@ function* anonymousLoginSaga(): Generator {
};
yield put(loginSuccess(currentUser as User));
} catch (error) {
- yield put(anonymousLoginFailed()); // fails silently
+ yield put(
+ anonymousLoginFailed({
+ code: 'unknown',
+ message: 'Critical error while login as anonymous.',
+ source: ProfileErrorSource.AnonymousLogin,
+ }),
+ );
}
}
diff --git a/web-app/src/app/store/store.ts b/web-app/src/app/store/store.ts
index 818884b23..ee575ca41 100644
--- a/web-app/src/app/store/store.ts
+++ b/web-app/src/app/store/store.ts
@@ -7,7 +7,7 @@ import {
PURGE,
REGISTER,
} from 'redux-persist';
-import storage from 'redux-persist/lib/storage/session';
+import storage from 'redux-persist/lib/storage';
import {
configureStore,
type ThunkAction,
diff --git a/web-app/src/app/types.ts b/web-app/src/app/types.ts
index 81344ea8e..9ad9b3eb4 100644
--- a/web-app/src/app/types.ts
+++ b/web-app/src/app/types.ts
@@ -81,6 +81,7 @@ export enum ProfileErrorSource {
Registration = 'Registration',
ResetPassword = 'ResetPassword',
VerifyEmail = 'VerifyEmail',
+ AnonymousLogin = 'AnonymousLogin',
}
export enum FeedErrorSource {
DatabaseAPI = 'DatabaseAPI',