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 : };
|