From cc809bcea745f000ee5a46eaf28f0ee31501d700 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 20 Apr 2025 15:55:30 +0530 Subject: [PATCH 1/9] feat(user): update user profile to include username and phone number, refactor preferences handling feat(auth0): add utility functions for updating user phone and deleting users from Auth0 chore(deps): add react-hook-form dependency to package.json and package-lock.json --- api-service/api/openapi.yaml | 20 ++-- api-service/service/StoreProfileService.js | 16 +-- api-service/service/UserProfileService.js | 133 +++++++++------------ api-service/utils/auth0Util.js | 67 ++++++++++- web/package-lock.json | 17 +++ web/package.json | 1 + web/src/api/types/data-contracts.ts | 8 +- 7 files changed, 156 insertions(+), 106 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 7d57f66..d2a95bb 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -722,11 +722,12 @@ components: UserUpdate: type: object properties: - preferences: - type: array - items: - $ref: "#/components/schemas/PreferenceItem" - description: User interest preferences with taxonomy categorization + username: + type: string + description: User's unique username + phone: + type: string + description: User's phone number (E.164 format recommended) privacySettings: type: object properties: @@ -734,14 +735,6 @@ components: type: boolean anonymizeData: type: boolean - optInStores: - type: array - items: - type: string - optOutStores: - type: array - items: - type: string dataAccess: type: object properties: @@ -749,6 +742,7 @@ components: type: array items: type: string + description: List of domains allowed to access user data via API keys ApiKey: type: object diff --git a/api-service/service/StoreProfileService.js b/api-service/service/StoreProfileService.js index 8291cca..f9a4e8f 100644 --- a/api-service/service/StoreProfileService.js +++ b/api-service/service/StoreProfileService.js @@ -1,10 +1,9 @@ -const axios = require('axios'); const { getDB } = require('../utils/mongoUtil'); const { setCache, getCache, invalidateCache } = require('../utils/redisUtil'); const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); -const { getManagementToken } = require('../utils/auth0Util'); +const { deleteAuth0User } = require('../utils/auth0Util'); /** * Get Store Profile @@ -107,17 +106,8 @@ exports.deleteStoreProfile = async function (req) { }); } - // Delete from Auth0 - try { - const managementToken = await getManagementToken(); - await axios.delete(`${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userData.sub}`, { - headers: { - Authorization: `Bearer ${managementToken}`, - }, - }); - } catch (error) { - console.error('Auth0 deletion failed:', error); - } + // Delete from Auth0 using the utility function + await deleteAuth0User(userData.sub); // Call the new function // Clear cache using standardized key await invalidateCache(`${CACHE_KEYS.STORE_DATA}${userData.sub}`); diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 45e96d0..e1d9c4d 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -3,9 +3,7 @@ const { setCache, getCache, invalidateCache } = require('../utils/redisUtil'); const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); -const { getManagementToken } = require('../utils/auth0Util'); -const AIService = require('../clients/AIService'); -const axios = require('axios'); // Added missing import +const { updateUserMetadata, updateUserPhone, deleteAuth0User } = require('../utils/auth0Util'); /** * Get User Profile @@ -56,105 +54,100 @@ exports.getUserProfile = async function (req) { exports.updateUserProfile = async function (req, body) { try { const db = getDB(); - - // Get user data - use req.user if available (from middleware) or fetch it const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const auth0UserId = userData.sub; - // If username is being updated, check for uniqueness - if (body.username) { + // --- Username Uniqueness Check --- + if (body.username && body.username !== userData.nickname) { // Check only if username changed const existingUser = await db.collection('users').findOne({ username: body.username, - auth0Id: { $ne: userData.sub }, + auth0Id: { $ne: auth0UserId }, }); - if (existingUser) { - return respondWithCode(409, { - code: 409, - message: 'Username already taken', - }); + return respondWithCode(409, { code: 409, message: 'Username already taken' }); + } + // Also update Auth0 nickname if username changes + try { + // Pass invalidateUserCache: true if you want the main user cache invalidated here + await updateUserMetadata(auth0UserId, { nickname: body.username } /*, true */); + } catch (auth0Error) { + console.error(`Failed to update Auth0 nickname for ${auth0UserId}:`, auth0Error); + // Log and continue DB update } } - // Preferences are managed separately, remove if present in body - // We still might need to call AI service if preferences *were* sent, - // but we won't save them directly here. - let preferencesToProcess = null; - if (body.preferences) { - preferencesToProcess = body.preferences; - delete body.preferences; // Remove from direct update data - } - - // If preferences were provided, send to FastAPI for processing - if (preferencesToProcess) { + // --- Phone Number Update in Auth0 --- + if (body.phone && body.phone !== userData.phone_number) { // Check only if phone changed try { - // Find user email if not readily available (needed for AI service) - const currentUser = await db.collection('users').findOne({ auth0Id: userData.sub }, { projection: { email: 1 } }); - if (currentUser?.email) { - await AIService.updateUserPreferences( - userData.sub, - currentUser.email, // Use fetched email - preferencesToProcess - ); - // Invalidate preferences cache as AI service might have updated them - await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); - } else { - console.error('Could not find user email to process preferences via AI service.'); - } - - } catch (error) { - console.error('Failed to process preferences through AI service:', error); - // Decide if failure here should prevent profile update or just log + // Call the utility function + await updateUserPhone(auth0UserId, body.phone); + // Optionally invalidate user cache here if phone update should trigger it + // await invalidateCache(`${CACHE_KEYS.USER_DATA}${auth0UserId}`); + } catch (auth0Error) { + // Error is already logged in updateUserPhone + // Decide if this should be a fatal error or just logged + // For now, log and continue DB update + // Consider returning a specific error if Auth0 update is critical + // return respondWithCode(500, { code: 500, message: 'Failed to update phone number with identity provider.' }); } } - // Update user + // --- Database Update --- const updateData = { updatedAt: new Date(), - ...body, // Apply other updates from body (excluding preferences) }; + if (body.username !== undefined) updateData.username = body.username; + if (body.phone !== undefined) updateData.phone = body.phone; + + // Only update allowed privacy settings + if (body.privacySettings !== undefined) { + updateData.privacySettings = {}; + if (body.privacySettings.dataSharingConsent !== undefined) { + updateData.privacySettings.dataSharingConsent = body.privacySettings.dataSharingConsent; + } + if (body.privacySettings.anonymizeData !== undefined) { + updateData.privacySettings.anonymizeData = body.privacySettings.anonymizeData; + } + // DO NOT update optInStores or optOutStores here + } - // Ensure only allowed fields are set explicitly if needed, or rely on body structure - // Example: - // if (body.username !== undefined) updateData.username = body.username; - // if (body.phone !== undefined) updateData.phone = body.phone; - // if (body.privacySettings !== undefined) updateData.privacySettings = body.privacySettings; - // if (body.dataAccess !== undefined) updateData.dataAccess = body.dataAccess; - + if (body.dataAccess !== undefined) updateData.dataAccess = body.dataAccess; const result = await db .collection('users') .findOneAndUpdate( - { auth0Id: userData.sub }, + { auth0Id: auth0UserId }, { $set: updateData }, - { returnDocument: 'after', projection: { preferences: 0 } }, // Exclude preferences from returned doc + { returnDocument: 'after', projection: { preferences: 0 } }, ); if (!result) { - return respondWithCode(404, { - code: 404, - message: 'User not found', - }); + return respondWithCode(404, { code: 404, message: 'User not found' }); } - // Invalidate user data cache - const cacheKey = `${CACHE_KEYS.USER_DATA}${userData.sub}`; + // --- Cache Invalidation --- + // Invalidate main user cache *after* successful DB update + const cacheKey = `${CACHE_KEYS.USER_DATA}${auth0UserId}`; await invalidateCache(cacheKey); - // If privacy settings change, it might affect store data access, invalidate those too: + // Invalidate store preferences if privacy settings changed if (updateData.privacySettings && result.privacySettings?.optInStores) { - // Invalidate store-specific preference caches for opted-in stores - const userObjectId = result._id; // Get the actual ObjectId + const userObjectId = result._id; for (const storeId of result.privacySettings.optInStores) { await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`); } } - + // --- Update Cache --- // Update cache with the new data (without preferences) + // Note: This happens *after* invalidation, ensuring fresh data is set if needed immediately await setCache(cacheKey, JSON.stringify(result), { EX: CACHE_TTL.USER_DATA }); - return respondWithCode(200, result); // result already excludes preferences + + return respondWithCode(200, result); } catch (error) { console.error('Update profile failed:', error); + // Check if the error came from Auth0 phone update and customize response if needed + // if (error.message.includes('Auth0 phone number')) { ... } return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; @@ -192,18 +185,8 @@ exports.deleteUserProfile = async function (req) { }); } - // Delete from Auth0 - try { - const managementToken = await getManagementToken(); - await axios.delete(`${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userData.sub}`, { - headers: { - Authorization: `Bearer ${managementToken}`, - }, - }); - } catch (error) { - // Log error but don't fail the request if Auth0 deletion fails - console.error('Auth0 deletion failed:', error.response?.data || error.message); - } + // Delete from Auth0 using the utility function + await deleteAuth0User(userData.sub); // Call the new function // Clear user-specific caches await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); diff --git a/api-service/utils/auth0Util.js b/api-service/utils/auth0Util.js index ddad5c8..b9990e6 100644 --- a/api-service/utils/auth0Util.js +++ b/api-service/utils/auth0Util.js @@ -153,6 +153,42 @@ async function updateUserMetadata(userId, metadata, invalidateUserCache = false) } } +/** + * Update Auth0 user phone number + * @param {string} userId - Auth0 user ID + * @param {string} phone - New phone number + * @returns {Promise} - Updated user data from Auth0 + */ +async function updateUserPhone(userId, phone) { + try { + const token = await getManagementToken(); + + const phoneUpdate = { + phone_number: phone, + // Consider if phone_verified should be reset here + // phone_verified: false, + }; + + // Update user phone number in Auth0 + const response = await axios.patch( + `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userId}`, + phoneUpdate, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ); + + return response.data; + } catch (error) { + console.error(`Failed to update Auth0 phone number for ${userId}:`, error?.response?.data || error.message); + // Re-throw the error so the calling service can decide how to handle it + throw error; + } +} + /** * Get Auth0 user metadata * @param {string} userId - Auth0 user ID @@ -180,10 +216,39 @@ async function getUserMetadata(userId) { } } +/** + * Delete a user from Auth0 + * @param {string} userId - Auth0 user ID + * @returns {Promise} + */ +async function deleteAuth0User(userId) { + try { + const token = await getManagementToken(); + + // Delete user from Auth0 + await axios.delete( + `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userId}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + console.log(`Successfully deleted user ${userId} from Auth0.`); + } catch (error) { + // Log error but don't throw, allowing the calling service to continue if needed + console.error(`Auth0 deletion failed for user ${userId}:`, error?.response?.data || error.message); + // If you want the deletion failure to stop the process in the service, re-throw the error: + // throw error; + } +} + module.exports = { getManagementToken, assignUserRole, linkAccounts, updateUserMetadata, - getUserMetadata + updateUserPhone, + getUserMetadata, + deleteAuth0User, }; diff --git a/web/package-lock.json b/web/package-lock.json index d3e7d50..d16e59f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "flowbite-react": "^0.11.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", "react-router": "^7.4.0" }, @@ -4811,6 +4812,22 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.56.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.0.tgz", + "integrity": "sha512-U2QQgx5z2Y8Z0qlXv3W19hWHJgfKdWMz0O/osuY+o+CYq568V2R/JhzC6OAXfR8k24rIN0Muan2Qliaq9eKs/g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", diff --git a/web/package.json b/web/package.json index 0e19a85..78dfae6 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "flowbite-react": "^0.11.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", "react-router": "^7.4.0" }, diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 8082911..dd5acd8 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -87,13 +87,13 @@ export interface StoreCreate { } export interface UserUpdate { - /** User interest preferences with taxonomy categorization */ - preferences?: PreferenceItem[]; + /** User's unique username */ + username?: string; + /** User's phone number (E.164 format recommended) */ + phone?: string; privacySettings?: { dataSharingConsent?: boolean; anonymizeData?: boolean; - optInStores?: string[]; - optOutStores?: string[]; }; dataAccess?: { allowedDomains?: string[]; From 37197b95fb5392879109c83d80a1b7b873652e37 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 20 Apr 2025 16:31:42 +0530 Subject: [PATCH 2/9] feat(profile): add user and store profile pages with form handling and privacy settings --- api-service/api/openapi.yaml | 17 ++- api-service/utils/auth0Util.js | 2 +- web/src/api/types/data-contracts.ts | 14 +- web/src/layout/Header.tsx | 15 ++ web/src/main.tsx | 12 ++ web/src/pages/StoreProfilePage.tsx | 153 +++++++++++++++++++++ web/src/pages/UserProfilePage.tsx | 204 ++++++++++++++++++++++++++++ 7 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 web/src/pages/StoreProfilePage.tsx create mode 100644 web/src/pages/UserProfilePage.tsx diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index d2a95bb..791fa6b 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -581,7 +581,6 @@ components: properties: userId: type: string - format: ObjectId readOnly: true description: Internal user ID auth0Id: @@ -728,6 +727,11 @@ components: phone: type: string description: User's phone number (E.164 format recommended) + preferences: + type: array + description: User interest preferences with taxonomy categorization + items: + $ref: "#/components/schemas/PreferenceItem" privacySettings: type: object properties: @@ -735,6 +739,16 @@ components: type: boolean anonymizeData: type: boolean + optInStores: + type: array + items: + type: string + description: List of store IDs the user has opted into sharing data with + optOutStores: + type: array + items: + type: string + description: List of store IDs the user has opted out of sharing data with dataAccess: type: object properties: @@ -894,7 +908,6 @@ components: properties: userId: type: string - format: ObjectId description: Internal user ID preferences: type: array diff --git a/api-service/utils/auth0Util.js b/api-service/utils/auth0Util.js index b9990e6..0dfbec1 100644 --- a/api-service/utils/auth0Util.js +++ b/api-service/utils/auth0Util.js @@ -166,7 +166,7 @@ async function updateUserPhone(userId, phone) { const phoneUpdate = { phone_number: phone, // Consider if phone_verified should be reset here - // phone_verified: false, + phone_verified: false, }; // Update user phone number in Auth0 diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index dd5acd8..074fe75 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -14,10 +14,7 @@ export type AttributeDistribution = Record; export interface User { - /** - * Internal user ID - * @format ObjectId - */ + /** Internal user ID */ userId?: string; /** Auth0 user ID */ auth0Id: string; @@ -91,9 +88,13 @@ export interface UserUpdate { username?: string; /** User's phone number (E.164 format recommended) */ phone?: string; + /** User interest preferences with taxonomy categorization */ + preferences?: PreferenceItem[]; privacySettings?: { dataSharingConsent?: boolean; anonymizeData?: boolean; + optInStores?: string[]; + optOutStores?: string[]; }; dataAccess?: { allowedDomains?: string[]; @@ -176,10 +177,7 @@ export interface SearchEntry { } export interface UserPreferences { - /** - * Internal user ID - * @format ObjectId - */ + /** Internal user ID */ userId?: string; preferences?: PreferenceItem[]; /** @format date-time */ diff --git a/web/src/layout/Header.tsx b/web/src/layout/Header.tsx index 81428ea..8412362 100644 --- a/web/src/layout/Header.tsx +++ b/web/src/layout/Header.tsx @@ -32,6 +32,17 @@ export function Header() { return "/"; }; + // Determine profile link based on role + const getProfileLink = () => { + if (userRoles.includes("store")) { + return "/profile/store"; + } + if (userRoles.includes("user")) { + return "/profile/user"; + } + return "/"; + }; + const handleLogin = () => login(); const handleLogout = () => logout(); @@ -72,6 +83,10 @@ export function Header() { {user?.email} + {/* Add Profile Link */} + + Profile + Sign out diff --git a/web/src/main.tsx b/web/src/main.tsx index 1245273..d101181 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -14,6 +14,8 @@ import StoreDashboard from "./pages/StoreDashboard"; import { AuthProviderWrapper } from "./context/AuthContext"; import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; +import UserProfilePage from "./pages/UserProfilePage"; +import StoreProfilePage from "./pages/StoreProfilePage"; const router = createBrowserRouter([ { @@ -44,6 +46,11 @@ const router = createBrowserRouter([ path: "dashboard/user", element: , }, + // Add User Profile Route + { + path: "profile/user", + element: , + }, ], }, // --- Protected Store Routes --- @@ -54,6 +61,11 @@ const router = createBrowserRouter([ path: "dashboard/store", element: , }, + // Add Store Profile Route + { + path: "profile/store", + element: , + }, ], }, // --- Catch-all 404 Route --- diff --git a/web/src/pages/StoreProfilePage.tsx b/web/src/pages/StoreProfilePage.tsx new file mode 100644 index 0000000..34dcb56 --- /dev/null +++ b/web/src/pages/StoreProfilePage.tsx @@ -0,0 +1,153 @@ +import { useEffect } from "react"; +import { useForm, SubmitHandler } from "react-hook-form"; +import { + Button, + Card, + // Remove Textarea and Label imports if no longer needed elsewhere + // Textarea, + // Label, + Alert, + FloatingLabel, // Ensure this is imported + HelperText, // Ensure this is imported + Spinner, // Import Spinner +} from "flowbite-react"; +import { + useStoreProfile, + useUpdateStoreProfile, +} from "../api/hooks/useStoreHooks"; +import { StoreUpdate } from "../api/types/data-contracts"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; + +// Define the form data structure based on StoreUpdate schema +type StoreProfileFormData = { + name?: string; + address?: string; +}; + +export default function StoreProfilePage() { + const { + data: storeProfile, + isLoading, + error: fetchError, + } = useStoreProfile(); + const { + mutate: updateStore, + isPending: isUpdating, + error: updateError, + isSuccess, + } = useUpdateStoreProfile(); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + // Set default values + defaultValues: { + name: "", + address: "", + }, + }); + + // Pre-fill form when storeProfile data loads + useEffect(() => { + if (storeProfile) { + reset({ + name: storeProfile.name || "", + address: storeProfile.address || "", + }); + } + }, [storeProfile, reset]); + + const onSubmit: SubmitHandler = (data) => { + const updatePayload: StoreUpdate = { + name: data.name, + address: data.address, + }; + updateStore(updatePayload); + }; + + // Use LoadingSpinner for loading state + if (isLoading) { + return ; + } + + // Use ErrorDisplay for fetch error state + if (fetchError) { + return ( + + ); + } + + return ( +
+ +

Store Profile

+ {isSuccess && ( + {}} className="mb-4"> + Profile updated successfully! + + )} + {updateError && ( + + )} +
+ {/* Use FloatingLabel for Store Name */} +
+ + {errors.name?.message && ( + {errors.name.message} + )} +
+ + {/* Use FloatingLabel for Address */} +
+ , not