LCOV - code coverage report
Current view: top level - src/contexts/UserContext.jsx - UserContext.jsx (source / functions) Hit Total Coverage
Test: changed-lcov.info Lines: 51 179 28.5 %
Date: 2025-11-22 00:01:26 Functions: 2 5 40.0 %

          Line data    Source code
       1             : // Filepath: Melodex/melodex-front-end/src/contexts/UserContext.jsx
       2           1 : import React, { createContext, useState, useEffect, useContext } from 'react';
       3           1 : import { Auth } from 'aws-amplify';
       4           1 : import { useNavigate, useLocation } from 'react-router-dom';
       5             : 
       6           1 : const isCypressEnv = typeof window !== 'undefined' && !!(window).Cypress;
       7           1 : const LAST_UID_KEY = "melodex_last_uid";
       8             : 
       9           1 : const decodeJwt = (token) => {
      10           0 :   try {
      11           0 :     const base64Url = token.split('.')[1];
      12           0 :     const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
      13           0 :     const jsonPayload = decodeURIComponent(
      14           0 :       atob(base64)
      15           0 :         .split('')
      16           0 :         .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
      17           0 :         .join('')
      18           0 :     );
      19           0 :     return JSON.parse(jsonPayload);
      20           0 :   } catch (error) {
      21           0 :     console.error('Failed to decode JWT token:', error);
      22           0 :     return {};
      23           0 :   }
      24           0 : };
      25             : 
      26           1 : const testImageUrl = (url) => {
      27           0 :   return new Promise((resolve) => {
      28           0 :     const img = new Image();
      29           0 :     img.onload = () => resolve(true);
      30           0 :     img.onerror = () => resolve(false);
      31           0 :     img.src = url;
      32           0 :   });
      33           0 : };
      34             : 
      35           1 : export const UserContext = createContext();
      36             : 
      37           1 : export const UserProvider = ({ children }) => {
      38          14 :   const [userID, setUserID] = useState(null);
      39          14 :   const [displayName, setDisplayName] = useState(null);
      40          14 :   const [userPicture, setUserPicture] = useState(null);
      41          14 :   const [email, setEmail] = useState(null);
      42          14 :   const [loading, setLoading] = useState(true);
      43          14 :   const navigate = useNavigate();
      44          14 :   const location = useLocation();
      45             : 
      46             :   // Test-only toggle set by Cypress: window.__E2E_REQUIRE_AUTH__ = true to disable bypass
      47          14 :   const requireAuth =
      48          14 :     typeof window !== 'undefined' && !!(window).__E2E_REQUIRE_AUTH__;
      49             : 
      50          14 :   const checkUser = async () => {
      51           0 :     try {
      52           0 :       const user = await Auth.currentAuthenticatedUser({
      53           0 :         bypassCache: true,
      54           0 :       });
      55           0 :       console.log("Authenticated user:", user);
      56             : 
      57           0 :       const extractedUserID =
      58           0 :         user.username || user.attributes?.sub || user.id;
      59           0 :       console.log("Extracted userID:", extractedUserID);
      60             : 
      61             :       // If we detect a different Melodex user than last time, clear any
      62             :       // existing Spotify session so tokens can't leak across accounts.
      63           0 :       try {
      64           0 :         const lastUid = localStorage.getItem(LAST_UID_KEY);
      65             : 
      66           0 :         if (lastUid && lastUid !== extractedUserID) {
      67           0 :           const rawBase =
      68           0 :             import.meta.env.VITE_API_BASE_URL ??
      69           0 :             import.meta.env.VITE_API_BASE ??
      70           0 :             (typeof window !== "undefined"
      71           0 :               ? window.__API_BASE__
      72           0 :               : null) ??
      73           0 :             "http://localhost:8080";
      74             : 
      75           0 :           const baseNoTrail = String(rawBase).replace(/\/+$/, "");
      76           0 :           const hasApiSuffix = /\/api$/.test(baseNoTrail);
      77           0 :           const authRoot = hasApiSuffix
      78           0 :             ? baseNoTrail.replace(/\/api$/, "")
      79           0 :             : baseNoTrail;
      80             : 
      81           0 :           await fetch(`${authRoot}/auth/revoke`, {
      82           0 :             method: "POST",
      83           0 :             credentials: "include",
      84           0 :           });
      85             : 
      86           0 :           console.log(
      87           0 :             "Revoked Spotify session due to Melodex user switch"
      88           0 :           );
      89           0 :         }
      90             : 
      91             :         // Always record the current user as the last-seen user
      92           0 :         localStorage.setItem(LAST_UID_KEY, extractedUserID);
      93           0 :       } catch (revokeErr) {
      94           0 :         console.warn(
      95           0 :           "Failed to revoke Spotify session on user switch:",
      96           0 :           revokeErr
      97           0 :         );
      98           0 :       }
      99             : 
     100           0 :       let attributeMap = user.attributes || {};
     101           0 :       if (!user.attributes && user.token) {
     102           0 :         console.log('No attributes available, decoding ID token');
     103           0 :         attributeMap = decodeJwt(user.token);
     104           0 :         console.log('Decoded ID token attributes:', attributeMap);
     105           0 :       } else if (!user.attributes) {
     106           0 :         console.log('No attributes or token available in user object, skipping attribute fetch');
     107           0 :       }
     108             : 
     109             :       // Fetch user attributes from Cognito if not already present
     110           0 :       if (
     111           0 :         !attributeMap['custom:uploadedPicture'] &&
     112           0 :         !attributeMap['custom:picture'] &&
     113           0 :         !attributeMap.picture
     114           0 :       ) {
     115           0 :         try {
     116           0 :           const attributes = await Auth.userAttributes(user);
     117           0 :           console.log('Fetched Cognito user attributes:', attributes);
     118           0 :           attributeMap = attributes.reduce((acc, attr) => {
     119           0 :             acc[attr.Name] = attr.Value;
     120           0 :             return acc;
     121           0 :           }, {});
     122           0 :           console.log('Converted Cognito attributes to map:', attributeMap);
     123           0 :         } catch (attrError) {
     124           0 :           console.error('Failed to fetch Cognito user attributes:', attrError);
     125           0 :         }
     126           0 :       }
     127             : 
     128           0 :       const name = attributeMap.name || attributeMap['given_name'] || 'User';
     129           0 :       setDisplayName(name);
     130             : 
     131           0 :       let picture =
     132           0 :         attributeMap['custom:uploadedPicture'] ||
     133           0 :         attributeMap['custom:picture'] ||
     134           0 :         attributeMap.picture ||
     135           0 :         'https://i.imgur.com/uPnNK9Y.png';
     136           0 :       const isPictureValid = await testImageUrl(picture);
     137           0 :       if (!isPictureValid) {
     138           0 :         picture = 'https://i.imgur.com/uPnNK9Y.png';
     139           0 :       }
     140           0 :       setUserPicture(picture);
     141             : 
     142           0 :       const userEmail = attributeMap.email || 'N/A';
     143           0 :       setEmail(userEmail);
     144             : 
     145           0 :       setUserID(extractedUserID);
     146           0 :       setLoading(false);
     147             : 
     148             :       // ✅ Only auto-redirect to /rank if we're on login/root, and only when not bypassing
     149           0 :       if (
     150           0 :         (!isCypressEnv || requireAuth) &&
     151           0 :         (location.pathname === '/login' || location.pathname === '/')
     152           0 :       ) {
     153           0 :         console.log('User authenticated, redirecting to /rank');
     154           0 :         navigate('/rank');
     155           0 :       }
     156           0 :     } catch (error) {
     157           0 :       console.log('No user authenticated, setting defaults:', error);
     158           0 :       setUserID(null);
     159           0 :       setDisplayName(null);
     160           0 :       setUserPicture('https://i.imgur.com/uPnNK9Y.png');
     161           0 :       setEmail(null);
     162           0 :       setLoading(false);
     163             : 
     164             :       // ✅ Redirect unauthenticated users to /login in prod OR when E2E requires auth
     165           0 :       if (
     166           0 :         (!isCypressEnv || requireAuth) &&
     167           0 :         location.pathname !== '/login' &&
     168           0 :         location.pathname !== '/register'
     169           0 :       ) {
     170           0 :         console.log('No user authenticated, redirecting to /login');
     171           0 :         navigate('/login');
     172           0 :       }
     173           0 :     }
     174           0 :   };
     175             : 
     176          14 :   useEffect(() => {
     177           7 :     if (isCypressEnv && !requireAuth) {
     178             :       // Fast-path for E2E: skip Amplify, mark as "logged in"
     179           7 :       setUserID('e2e-user');
     180           7 :       setDisplayName('E2E User');
     181           7 :       setUserPicture('https://i.imgur.com/uPnNK9Y.png');
     182           7 :       setEmail('e2e@example.com');
     183           7 :       setLoading(false);
     184           7 :       return;
     185           7 :     }
     186           0 :     checkUser();
     187             :     // eslint-disable-next-line react-hooks/exhaustive-deps
     188          14 :   }, []);
     189             : 
     190          14 :   return (
     191          14 :     <UserContext.Provider
     192          14 :       value={{
     193          14 :         userID,
     194          14 :         setUserID,
     195          14 :         displayName,
     196          14 :         setDisplayName,
     197          14 :         userPicture,
     198          14 :         setUserPicture,
     199          14 :         setProfilePicture: setUserPicture, // alias for existing callers
     200          14 :         email,
     201          14 :         checkUser,
     202          14 :         loading,
     203          14 :       }}
     204             :     >
     205          14 :       {children}
     206          14 :     </UserContext.Provider>
     207             :   );
     208          14 : };
     209             : 
     210           1 : export const useUserContext = () => {
     211          51 :   const context = useContext(UserContext);
     212          51 :   if (!context) {
     213           0 :     throw new Error('useUserContext must be used within a UserProvider');
     214           0 :   }
     215          51 :   return context;
     216          51 : };

Generated by: LCOV version 1.15.alpha0w