Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

currentUser is null after logging in in app/page.tsx #306

Open
pashpashpash opened this issue Aug 23, 2024 · 3 comments · May be fixed by #307
Open

currentUser is null after logging in in app/page.tsx #306

pashpashpash opened this issue Aug 23, 2024 · 3 comments · May be fixed by #307

Comments

@pashpashpash
Copy link

I understand that page.tsx is a server rendered component, but is it possible to update currentUser somehow after a log in happens?

The flow I want:

  1. Sign in modal is shown on landing page
  2. If user is authenticated (currentUser is not null), page.tsx stops showing sign in modal

It works after refreshing, but without refreshing, currentUser remains null. What's the best practice to deal with this?

@jamezening
Copy link

Was just about to create an issue for this myself.

To elaborate a bit, even with export const dynamic = 'force-dynamic'; in the layout,
after logging in and even with a router.refresh(); call (as included in Header),

const { currentUser } = await getAuthenticatedAppForUser(); continues to be null unless browser is refreshed or window.location.reload() is called, which is not ideal.

As pashpashpash mentioned, is there a best practice to implement currentUser being synced/update when logging in?

src/components/Header.jsx

  useEffect(() => {
    onAuthStateChanged((authUser) => {
      if (user === undefined) return;

      // refresh when user changed to ease testing
      if (user?.email !== authUser?.email) {
        router.refresh();
      }
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [user]); 

src/app/layout.js

import '@/src/app/styles.css';
import Header from '@/src/components/Header.jsx';
import { getAuthenticatedAppForUser } from '@/src/lib/firebase/serverApp';
// Force next.js to treat this route as server-side rendered
// Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it
export const dynamic = 'force-dynamic';

export const metadata = {
  title: 'FriendlyEats',
  description:
    'FriendlyEats is a restaurant review website built with Next.js and Firebase.',
};

export default async function RootLayout({ children }) {
  const { currentUser } = await getAuthenticatedAppForUser();
  return (
    <html lang="en">
      <body>
        <Header initialUser={currentUser?.toJSON()} />

        <main>{children}</main>
      </body>
    </html>
  );
}

@pashpashpash
Copy link
Author

pashpashpash commented Aug 29, 2024

To elaborate a bit, even with export const dynamic = 'force-dynamic'; in the layout, after logging in and even with a router.refresh(); call (as included in Header),

@jamezening I ended up figuring it out. It seems like the currentUser is null because the service worker doesn't update its cached token right after the login happens. I fixed this by doing this right after the login:

  const handleSignIn = async () => {
        try {
            await signInWithGoogle();
        
            // Ensure token is immediately cached after login
            const idToken = await auth.currentUser?.getIdToken(true);
            if (idToken) {
                console.log('Caching token immediately after sign-in:', idToken);
                
                if (navigator.serviceWorker.controller) {
                    navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TOKEN', token: idToken });
                }

                // Cache in IndexedDB as a fallback
                const db = await openDB(DB_NAME, 1);
                await db.put(DB_STORE_NAME, { token: idToken, expirationTime: Date.now() + 3600 * 1000 }, TOKEN_KEY);
                console.log('Token cached in IndexedDB as a backup.');
            }
        } catch (error) {
            console.error('Error during sign-in:', error);
        }
    };
    
I'm telling the service worker here to cache the token as soon as we have it available post login. 

My full service worker code:

    import { openDB } from 'idb';

const CACHE_NAME = 'config-cache-v1';
const TOKEN_KEY = 'auth-token';
const TOKEN_CACHED_RECEIPT_KEY = 'token-cached-receipt';
const DB_NAME = 'auth-db';
const DB_STORE_NAME = 'tokens';

async function openDatabase() {
  return openDB(DB_NAME, 1, {
    upgrade(db) {
      if (!db.objectStoreNames.contains(DB_STORE_NAME)) {
        db.createObjectStore(DB_STORE_NAME);
      }
    }
  });
}

async function cacheAuthToken(token) {
  const expirationTime = Date.now() + 3600 * 1000; // Token expires in 1 hour
  const data = JSON.stringify({ token, expirationTime });
  const receipt = { status: 'success', timestamp: Date.now() };

  // Cache in service worker cache
  const cache = await caches.open(CACHE_NAME);
  const response = new Response(data, { headers: { 'Content-Type': 'application/json' } });
  await cache.put(TOKEN_KEY, response);

  // Cache in IndexedDB
  const db = await openDatabase();
  await db.put(DB_STORE_NAME, { token, expirationTime }, TOKEN_KEY);

  // Notify clients that the token was cached successfully
  const clientsList = await clients.matchAll();
  clientsList.forEach(client => client.postMessage({ type: 'TOKEN_CACHED', receipt }));
}

async function getCachedAuthToken() {
  const cache = await caches.open(CACHE_NAME);
  const response = await cache.match(TOKEN_KEY);

  if (response) {
    const { token, expirationTime } = await response.json();
    if (Date.now() > expirationTime) {
      await cache.delete(TOKEN_KEY);
      return null;
    }
    return token;
  }

  // Fallback to IndexedDB
  const db = await openDatabase();
  const storedData = await db.get(DB_STORE_NAME, TOKEN_KEY);
  if (storedData && Date.now() < storedData.expirationTime) {
    return storedData.token;
  }

  return null;
}

async function clearAuthToken() {
  // Clear from service worker cache
  const cache = await caches.open(CACHE_NAME);
  await cache.delete(TOKEN_KEY);

  // Clear from IndexedDB
  const db = await openDatabase();
  await db.delete(DB_STORE_NAME, TOKEN_KEY);
}

self.addEventListener('fetch', (event) => {
  const { origin } = new URL(event.request.url);
  if (origin !== self.location.origin) return;

  event.respondWith(
    (async () => {
      const authToken = await getCachedAuthToken();
      const headers = new Headers(event.request.headers);

      if (authToken) {
        headers.append('Authorization', `Bearer ${authToken}`);
      }

      const modifiedRequest = new Request(event.request, { headers });
      return fetch(modifiedRequest);
    })()
  );
});

self.addEventListener("message", (event) => {
  if (event.data && event.data.type === "CACHE_TOKEN") {
    cacheAuthToken(event.data.token).then(() => {
      console.log("Service Worker: Token cached successfully.");
    });
  } else if (event.data && event.data.type === "CLEAR_TOKEN") {
    clearAuthToken().then(() => {
      console.log("Service Worker: Token cleared successfully.");
    });
  }
});

// Installation and Activation for Immediate Control
self.addEventListener('install', (event) => {
  const serializedFirebaseConfig = new URL(location).searchParams.get('firebaseConfig');
  if (!serializedFirebaseConfig) {
    throw new Error('Firebase Config object not found in service worker query string.');
  }
  self.skipWaiting();
  event.waitUntil(saveConfig(serializedFirebaseConfig));
});

self.addEventListener("activate", (event) => {
  event.waitUntil(clients.claim());
});

My full AuthProvider.tsx code for your reference:

'use client'
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { openDB } from 'idb';
import {
    signInWithGoogle,
    signOut,
    onAuthStateChanged
} from '@/src/lib/firebase/auth';
import { auth } from '@/src/lib/firebase/clientApp';
import { firebaseConfig } from '@/src/lib/firebase/config';

const DB_NAME = 'auth-db';
const DB_STORE_NAME = 'tokens';
const TOKEN_KEY = 'auth-token';

interface AuthContextType {
    user: any | null;
    handleSignIn: () => Promise<void>;
    handleSignOut: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const useAuth = (): AuthContextType => {
    const context = useContext(AuthContext);
    if (!context) {
        throw new Error('useAuth must be used within an AuthProvider');
    }
    return context;
};

interface AuthProviderProps {
    initialUser: any | null;
    children: ReactNode;
}

export function AuthProvider({ initialUser, children }: AuthProviderProps) {
    const [user, setUser] = useState(initialUser);
    const router = useRouter();

    useEffect(() => {
        if ('serviceWorker' in navigator) {
            const serializedFirebaseConfig = encodeURIComponent(JSON.stringify(firebaseConfig));
            const serviceWorkerUrl = `/auth-service-worker.js?firebaseConfig=${serializedFirebaseConfig}`;

            navigator.serviceWorker
                .register(serviceWorkerUrl)
                .then((registration) => {
                    console.log('Service Worker scope:', registration.scope);
                    return navigator.serviceWorker.ready;
                })
                .then(() => {
                    console.log('Service Worker is ready and controlling the page.');
                    if (!navigator.serviceWorker.controller) {
                        console.warn('However, due to a shift-reload, the service worker controller is bypassed entirely.');
                        location.reload();
                    }
                });
        }
    }, []);

    useEffect(() => {
        const unsubscribe = onAuthStateChanged(async (authUser) => {
            setUser(authUser);
            console.log('Auth state changed:', authUser);

            if (authUser) {
                console.log('Attempting to cache the token.');
                let idToken = authUser?.accessToken;
                if (!idToken) {
                    idToken = await auth.currentUser?.getIdToken();
                }

                console.log('idToken detected', { idToken });

                // Cache the token with the service worker if it's available
                if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
                    console.log('Caching token after auth state change:', idToken);
                    navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TOKEN', token: idToken });
                }

                // Also store the token in IndexedDB as a backup
                const db = await openDB(DB_NAME, 1, {
                    upgrade(db) {
                        db.createObjectStore(DB_STORE_NAME);
                    },
                });
                await db.put(DB_STORE_NAME, { token: idToken, expirationTime: Date.now() + 3600 * 1000 }, TOKEN_KEY);
                console.log('Token cached in IndexedDB as a backup.');
            }
        });

        return () => unsubscribe();
    }, []);

    useEffect(() => {
        onAuthStateChanged((authUser) => {
            if (user === undefined) return;

            if (user?.email !== authUser?.email) {
                router.refresh();
            }
        });
    }, [user, router]);

    const handleSignIn = async () => {
        try {
            await signInWithGoogle();
        
            // Ensure token is immediately cached after login
            const idToken = await auth.currentUser?.getIdToken(true);
            if (idToken) {
                console.log('Caching token immediately after sign-in:', idToken);
                
                if (navigator.serviceWorker.controller) {
                    navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TOKEN', token: idToken });
                }

                // Cache in IndexedDB as a fallback
                const db = await openDB(DB_NAME, 1);
                await db.put(DB_STORE_NAME, { token: idToken, expirationTime: Date.now() + 3600 * 1000 }, TOKEN_KEY);
                console.log('Token cached in IndexedDB as a backup.');
            }
        } catch (error) {
            console.error('Error during sign-in:', error);
        }
    };

    const handleSignOut = () => {
        signOut().then(async () => {
            if (navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({ type: 'CLEAR_TOKEN' });
            }

            // Clear from IndexedDB
            const db = await openDB(DB_NAME, 1);
            await db.delete(DB_STORE_NAME, TOKEN_KEY);
            console.log('Token cleared from IndexedDB.');
            
            setUser(null);
            router.refresh();
        });
    };

    return (
        <AuthContext.Provider value={{ user, handleSignIn, handleSignOut }}>
            {children}
        </AuthContext.Provider>
    );
}

I put my authprovider around everything in the top level layout.tsx. Hopefully this helps. But now it works great for me @jamezening

@RonakDoshiTMI
Copy link

+1

@jamesdaniels jamesdaniels linked a pull request Sep 5, 2024 that will close this issue
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants