diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 7d57f66..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: @@ -722,11 +721,17 @@ components: UserUpdate: type: object properties: + username: + type: string + description: User's unique username + 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" - description: User interest preferences with taxonomy categorization privacySettings: type: object properties: @@ -738,10 +743,12 @@ components: 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: @@ -749,6 +756,7 @@ components: type: array items: type: string + description: List of domains allowed to access user data via API keys ApiKey: type: object @@ -900,7 +908,6 @@ components: properties: userId: type: string - format: ObjectId description: Internal user ID preferences: type: array diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index 8ad633f..94e5e36 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -89,7 +89,7 @@ exports.registerUser = async function (req, body) { // Create user in database const user = { auth0Id: userData.sub, - username: userData.nickname, + username: userData.username, email: userData.email, phone: userData.phone_number || null, preferences: preferences || [], 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..100219a 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -3,9 +3,8 @@ 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 +// Import the new function +const { updateUserMetadata, updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); /** * Get User Profile @@ -56,106 +55,108 @@ 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 + // --- Local DB Username Uniqueness Check --- + // Keep this check for your application's internal username uniqueness if (body.username) { 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 in application' }); } } - // 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 + // --- Auth0 Username Update --- + // Attempt to update the Auth0 username if provided in the body. + // Auth0 will enforce its own uniqueness rules per connection. + if (body.username) { + try { + await updateAuth0Username(auth0UserId, body.username); + // Optionally: Update nickname in metadata as well if desired + // await updateUserMetadata(auth0UserId, { nickname: body.username }); + } catch (auth0Error) { + // If Auth0 update fails (e.g., username exists in Auth0 connection), return an error + // You might want to check the specific error type from auth0Error + console.error(`Auth0 username update failed for ${auth0UserId}:`, auth0Error); + return respondWithCode(409, { // Use 409 Conflict or appropriate code + code: 409, + message: 'Failed to update username with identity provider. It might already be taken.', + // Optionally include details: details: auth0Error.message + }); + } } - // If preferences were provided, send to FastAPI for processing - if (preferencesToProcess) { + // --- Phone Number Update in Auth0 --- + if (body.phone && body.phone !== userData.phone_number) { 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 + await updateUserPhone(auth0UserId, body.phone); + } catch (auth0Error) { + // Log and continue, or return error as needed + console.error(`Auth0 phone update failed for ${auth0UserId}:`, auth0Error); + // 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) }; + // Update local DB username only if Auth0 update was successful (or not attempted) + 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', - }); + // This case might occur if the user was deleted between checks + return respondWithCode(404, { code: 404, message: 'User not found during final update' }); } - // Invalidate user data cache - const cacheKey = `${CACHE_KEYS.USER_DATA}${userData.sub}`; + // --- Cache Invalidation & 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 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) { + // Catch errors not handled specifically above console.error('Update profile failed:', error); - return respondWithCode(500, { code: 500, message: 'Internal server error' }); + return respondWithCode(500, { code: 500, message: 'Internal server error during profile update' }); } }; @@ -192,18 +193,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..593b90b 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,78 @@ async function getUserMetadata(userId) { } } +/** + * Update Auth0 user's root username attribute (for database connections) + * @param {string} userId - Auth0 user ID + * @param {string} newUsername - The new username + * @returns {Promise} - Updated user data from Auth0 + */ +async function updateAuth0Username(userId, newUsername) { + try { + const token = await getManagementToken(); + + const usernameUpdate = { + username: newUsername, + // Note: You might need to consider connection-specific rules here. + // For Auth0 database connections, 'username' is the field. + }; + + // Update username using the Management API + const response = await axios.patch( + `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userId}`, + usernameUpdate, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ); + + console.log(`Successfully updated Auth0 username for ${userId}.`); + return response.data; + } catch (error) { + // Log the specific error (e.g., username already exists) + console.error(`Failed to update Auth0 username for ${userId}:`, error?.response?.data || error.message); + // Re-throw the error so the calling service knows the update failed + throw error; + } +} + +/** + * 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, + updateAuth0Username, // <-- Export the new function + 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..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; @@ -87,6 +84,10 @@ export interface StoreCreate { } export interface UserUpdate { + /** User's unique username */ + username?: string; + /** User's phone number (E.164 format recommended) */ + phone?: string; /** User interest preferences with taxonomy categorization */ preferences?: PreferenceItem[]; privacySettings?: { @@ -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/components/auth/RegistrationCompletionModal.tsx b/web/src/components/auth/RegistrationCompletionModal.tsx index 64867c0..dfc552d 100644 --- a/web/src/components/auth/RegistrationCompletionModal.tsx +++ b/web/src/components/auth/RegistrationCompletionModal.tsx @@ -64,7 +64,7 @@ export function RegistrationCompletionModal() { }; return ( - + diff --git a/web/src/components/auth/RegistrationProgress.tsx b/web/src/components/auth/RegistrationProgress.tsx index 7adad3f..662e100 100644 --- a/web/src/components/auth/RegistrationProgress.tsx +++ b/web/src/components/auth/RegistrationProgress.tsx @@ -13,12 +13,10 @@ export function RegistrationProgress({ return (
- {/* Add dark mode text color */}
Step {step} of {totalSteps}
- {/* Flowbite Progress handles its own dark mode */} - +
); } diff --git a/web/src/components/auth/RegistrationTypeSelector.tsx b/web/src/components/auth/RegistrationTypeSelector.tsx index 14429dd..e1e402d 100644 --- a/web/src/components/auth/RegistrationTypeSelector.tsx +++ b/web/src/components/auth/RegistrationTypeSelector.tsx @@ -31,35 +31,42 @@ export function RegistrationTypeSelector({
- {/* Flowbite Card handles dark mode background/border */} + {/* User Card */} handleTypeSelect("user")} > -
+ {/* Add min-h-* to ensure consistent height */} +
+ {" "} + {/* Adjust min-h value as needed */} - {/* Add dark mode text color */} -
+
+ {" "} + {/* Ensure text-center */} Individual User
- {/* Add dark mode text color */}

Get personalized recommendations while browsing stores

+ {/* Store Card */} handleTypeSelect("store")} > -
+ {/* Add min-h-* to ensure consistent height */} +
+ {" "} + {/* Adjust min-h value as needed */} - {/* Add dark mode text color */} -
+
+ {" "} + {/* Ensure text-center */} Store Owner
- {/* Add dark mode text color */}

Integrate with our API to provide targeted recommendations

diff --git a/web/src/components/auth/StoreRegistrationForm.tsx b/web/src/components/auth/StoreRegistrationForm.tsx index 3b2b396..0ce97db 100644 --- a/web/src/components/auth/StoreRegistrationForm.tsx +++ b/web/src/components/auth/StoreRegistrationForm.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Button, Label, TextInput } from "flowbite-react"; +import { useForm, SubmitHandler } from "react-hook-form"; // Import useForm and SubmitHandler +import { Button, FloatingLabel, HelperText } from "flowbite-react"; import { StoreCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; @@ -8,77 +8,102 @@ interface StoreRegistrationFormProps { isLoading: boolean; } +// Define form data type +type StoreRegistrationFormData = { + name: string; + address?: string; // Address is optional based on current setup +}; + export function StoreRegistrationForm({ onSubmit, isLoading, }: StoreRegistrationFormProps) { - const [name, setName] = useState(""); - const [address, setAddress] = useState(""); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + // Initialize react-hook-form + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + // Use the handleSubmit from react-hook-form + const handleFormSubmit: SubmitHandler = (data) => { const storeData: StoreCreate = { - name, - address, - webhooks: [], // We can leave this empty for now + name: data.name, + address: data.address || "", // Ensure address is string or empty string + webhooks: [], }; - onSubmit(storeData); }; return ( -
- {/* Add dark mode text color */} + // Use the handleSubmit from react-hook-form +

Complete Store Registration

-
-
- {/* Add dark mode text color */} - -
- {/* Flowbite TextInput handles dark mode */} - + setName(e.target.value)} - placeholder="Enter your store name" - required + label="Store Name" + color={errors.name ? "error" : "default"} // Use errors object + // Register the input with validation + {...register("name", { + required: "Store name is required", + minLength: { + value: 2, + message: "Name must be at least 2 characters", + }, + maxLength: { + value: 100, + message: "Name cannot exceed 100 characters", + }, + })} + required // Keep HTML required for accessibility/native behavior /> + {/* Display validation error */} + {errors.name && ( + + {errors.name.message} + + )}
-
-
- {/* Add dark mode text color */} - -
- {/* Flowbite TextInput handles dark mode */} - + setAddress(e.target.value)} - placeholder="Enter your store address" - required + label="Store Address" + color={errors.address ? "error" : "default"} // Use errors object + // Register the input (optional validation) + {...register("address", { + maxLength: { + value: 200, + message: "Address cannot exceed 200 characters", + }, + })} /> + {/* Display validation error */} + {errors.address && ( + + {errors.address.message} + + )} + {!errors.address && ( // Show helper text only if no error + + Optional: Provide a physical or primary business address. + + )}
{isLoading ? ( ) : ( - // Flowbite Button handles dark mode + // No need to manually disable based on name state anymore diff --git a/web/src/components/auth/UserRegistrationForm.tsx b/web/src/components/auth/UserRegistrationForm.tsx index 05f5005..09e8913 100644 --- a/web/src/components/auth/UserRegistrationForm.tsx +++ b/web/src/components/auth/UserRegistrationForm.tsx @@ -1,7 +1,17 @@ import { useState } from "react"; -import { Button, Checkbox, Label } from "flowbite-react"; +import { + Button, + Checkbox, + Label, + Modal, // Import Modal components + ModalHeader, + ModalBody, + ModalFooter, + Popover, // <-- Import Popover +} from "flowbite-react"; import { UserCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; +import { HiCheckCircle, HiInformationCircle } from "react-icons/hi"; // Import icons interface UserRegistrationFormProps { onSubmit: (userData: UserCreate) => void; @@ -12,53 +22,193 @@ export function UserRegistrationForm({ onSubmit, isLoading, }: UserRegistrationFormProps) { + // State for the main consent checkbox const [dataSharingConsent, setDataSharingConsent] = useState(false); + // State for the GDPR consent modal + const [showConsentModal, setShowConsentModal] = useState(false); + // State to track if consent has been explicitly accepted via the modal + const [consentAccepted, setConsentAccepted] = useState(false); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + // Ensure consent was actually accepted via the modal flow if required + if (!consentAccepted) { + // Optionally show an error or prompt to review consent + console.warn("Consent not explicitly accepted via modal."); + // Depending on strictness, you might prevent submission: + // return; + } const userData: UserCreate = { - dataSharingConsent, + // Use the state variable linked to the checkbox + dataSharingConsent: dataSharingConsent, preferences: [], // We can leave this empty for now }; onSubmit(userData); }; + const handleAcceptConsent = () => { + setDataSharingConsent(true); + setConsentAccepted(true); // Mark as accepted + setShowConsentModal(false); + }; + + const handleDeclineConsent = () => { + setDataSharingConsent(false); + setConsentAccepted(false); // Mark as not accepted (or reset) + setShowConsentModal(false); + }; + + // Content for the popover + const popoverContent = ( +
+

+ Please click 'Review Data Sharing Consent Details' first to read and + accept the terms. +

+
+ ); + return ( - - {/* Add dark mode text color */} -

- Complete User Registration -

+ <> + +

+ Complete User Registration +

+ + {/* Consent Section */} +
+ {/* Conditionally wrap Checkbox/Label in Popover */} + {!consentAccepted ? ( + + {/* This div is the target for the popover when checkbox is disabled */} +
+ {" "} + {/* Add styling for disabled look */} + + +
+
+ ) : ( + // Render the interactive Checkbox/Label when consent is accepted +
+ setDataSharingConsent(e.target.checked)} + required + /> + +
+ )} + + {/* Button to open the modal remains the same */} + + {/* Confirmation message remains the same */} + {consentAccepted && ( +

+ Consent Accepted +

+ )} +
-
- {/* Flowbite Checkbox handles dark mode */} - setDataSharingConsent(e.target.checked)} - required - /> - {/* Add dark mode text color */} - -
+ {/* Submit button logic remains the same */} +
+ {isLoading ? ( + + ) : ( + + )} +
+ -
- {isLoading ? ( - - ) : ( - // Flowbite Button handles dark mode - + - )} -
- + + + ); } diff --git a/web/src/components/common/ErrorDisplay.tsx b/web/src/components/common/ErrorDisplay.tsx index fc1a3e7..edea889 100644 --- a/web/src/components/common/ErrorDisplay.tsx +++ b/web/src/components/common/ErrorDisplay.tsx @@ -33,9 +33,11 @@ const ErrorDisplay: React.FC = ({
{/* Flowbite Alert handles its own dark mode styling */} - {/* Ensure title and message text have dark mode styles */} + {/* Use Flowbite's recommended text colors for alerts */}

{title}

-

{errorMessage}

+

+ {errorMessage} +

{/* Optional: Stack trace styling */} {/* {process.env.NODE_ENV === 'development' && error instanceof Error && (
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..f67a0fc
--- /dev/null
+++ b/web/src/pages/StoreProfilePage.tsx
@@ -0,0 +1,386 @@
+import { useEffect, useState } from "react";
+import { useForm, SubmitHandler } from "react-hook-form";
+import {
+  Button,
+  Card,
+  FloatingLabel,
+  HelperText,
+  Spinner,
+  Tabs,
+  Toast,
+  ToastToggle,
+  TabItem, // Corrected import
+  Modal, // Import Modal
+  ModalBody,
+  ModalHeader,
+} from "flowbite-react";
+// Import necessary icons
+import {
+  HiUserCircle,
+  HiCreditCard, // Keep HiCreditCard for Billing tab
+  HiCheck,
+  HiX,
+  HiLockClosed, // Import Lock icon for Security
+  HiTrash, // Import Trash icon for Delete
+  HiExclamation, // Import Exclamation icon for Modal
+} from "react-icons/hi";
+import {
+  useStoreProfile,
+  useUpdateStoreProfile,
+  useDeleteStoreProfile, // Import delete hook
+} from "../api/hooks/useStoreHooks";
+import { StoreUpdate } from "../api/types/data-contracts";
+import LoadingSpinner from "../components/common/LoadingSpinner";
+import ErrorDisplay from "../components/common/ErrorDisplay";
+import { useAuth } from "../hooks/useAuth"; // Import useAuth for logout
+
+// 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: isUpdateSuccess, // Rename for clarity
+    reset: resetUpdateMutation,
+  } = useUpdateStoreProfile();
+  const {
+    mutate: deleteStore,
+    isPending: isDeleting,
+    error: deleteError,
+    isSuccess: isDeleteSuccess, // Rename for clarity
+    reset: resetDeleteMutation,
+  } = useDeleteStoreProfile();
+  const { logout } = useAuth(); // Get logout function
+
+  // State for toasts
+  const [showSuccessToast, setShowSuccessToast] = useState(false);
+  const [showErrorToast, setShowErrorToast] = useState(false);
+  const [toastMessage, setToastMessage] = useState("");
+  // State for delete confirmation modal
+  const [showDeleteModal, setShowDeleteModal] = useState(false);
+
+  const {
+    register,
+    handleSubmit,
+    reset,
+    formState: { errors, isDirty },
+  } = useForm({
+    defaultValues: {
+      name: "",
+      address: "",
+    },
+  });
+
+  // Pre-fill form when storeProfile data loads
+  useEffect(() => {
+    if (storeProfile) {
+      reset({
+        name: storeProfile.name || "",
+        address: storeProfile.address || "",
+      });
+    }
+  }, [storeProfile, reset]);
+
+  // Show toasts based on UPDATE mutation state
+  useEffect(() => {
+    if (isUpdateSuccess) {
+      setShowSuccessToast(true);
+      setToastMessage("Store profile updated successfully!");
+      resetUpdateMutation();
+      const timer = setTimeout(() => setShowSuccessToast(false), 5000);
+      return () => clearTimeout(timer);
+    }
+  }, [isUpdateSuccess, resetUpdateMutation]);
+
+  useEffect(() => {
+    if (updateError) {
+      setShowErrorToast(true);
+      setToastMessage(
+        updateError.message ||
+          "Failed to update store profile. Please try again.",
+      );
+      resetUpdateMutation();
+      const timer = setTimeout(() => setShowErrorToast(false), 5000);
+      return () => clearTimeout(timer);
+    }
+  }, [updateError, resetUpdateMutation]);
+
+  // Show toasts/handle redirect based on DELETE mutation state
+  useEffect(() => {
+    if (isDeleteSuccess) {
+      setShowDeleteModal(false); // Close modal on success
+      // Don't show toast here, set flag for home page instead
+      // setShowSuccessToast(true);
+      // setToastMessage("Store account deleted successfully.");
+      sessionStorage.setItem(
+        "showPostLogoutToast",
+        "Store account deleted successfully.",
+      ); // <-- Set flag
+      resetDeleteMutation();
+      logout(); // <-- Call logout directly
+    }
+  }, [isDeleteSuccess, resetDeleteMutation, logout]); // Keep dependencies
+
+  useEffect(() => {
+    if (deleteError) {
+      // Keep modal open on error? Or close and show toast? Closing for now.
+      setShowDeleteModal(false);
+      setShowErrorToast(true);
+      setToastMessage(
+        deleteError.message ||
+          "Failed to delete store account. Please try again.",
+      );
+      resetDeleteMutation();
+      const timer = setTimeout(() => setShowErrorToast(false), 5000);
+      return () => clearTimeout(timer);
+    }
+  }, [deleteError, resetDeleteMutation]);
+
+  const onSubmit: SubmitHandler = (data) => {
+    const updatePayload: StoreUpdate = {
+      name: data.name,
+      address: data.address,
+    };
+    updateStore(updatePayload);
+  };
+
+  // Function to handle Auth0 password reset redirection
+  const handlePasswordReset = () => {
+    const domain = import.meta.env.VITE_AUTH0_DOMAIN;
+    window.open(`https://${domain}/passwordreset`, "_blank"); // Open in new tab
+  };
+
+  // Function to handle store deletion
+  const handleDeleteStore = () => {
+    deleteStore(); // Call the mutation
+  };
+
+  if (isLoading) {
+    return ;
+  }
+
+  if (fetchError) {
+    return (
+      
+    );
+  }
+
+  return (
+    
+ {/* Success Toast */} + {showSuccessToast && ( + +
+ +
+
{toastMessage}
+ setShowSuccessToast(false)} /> +
+ )} + + {/* Error Toast */} + {showErrorToast && ( + +
+ +
+
{toastMessage}
+ setShowErrorToast(false)} /> +
+ )} + + + + {/* Profile Tab */} + +
+

+ Store Information +

+ {/* Store Name */} +
+ + {errors.name?.message && ( + {errors.name.message} + )} +
+ + {/* Address */} +
+ + {errors.address?.message && ( + + {errors.address.message} + + )} +
+ + {/* Save Button */} + +
+
+ + {/* Security Tab - ADDED */} + +
+

+ Security Settings +

+ {/* Password Reset */} +
+

+ Password +

+

+ Manage your account password via our authentication provider. +

+ +
+ {/* MFA */} +
+

+ Multi-Factor Authentication (MFA) +

+

+ Add an extra layer of security to your account. (Coming Soon) +

+ +
+ {/* Delete Account */} +
+

+ Delete Account +

+

+ Permanently delete your store account and all associated data. + This action cannot be undone. +

+ +
+
+
+ + {/* Billing Tab (Placeholder) */} + +
+

+ Billing Information +

+

+ View your subscription details and manage payment methods. + (Coming Soon) +

+ {/* Add Billing details here later */} +
+
+
+
+ + {/* Delete Confirmation Modal */} + !isDeleting && setShowDeleteModal(false)} // Prevent closing while deleting + popup + > + + +
+ +

+ Are you sure you want to permanently delete your store account? +

+

+ All associated data, including API keys and usage history, will be + lost. This action cannot be undone. +

+
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/pages/UserProfilePage.tsx b/web/src/pages/UserProfilePage.tsx new file mode 100644 index 0000000..f8cf574 --- /dev/null +++ b/web/src/pages/UserProfilePage.tsx @@ -0,0 +1,456 @@ +import { useEffect, useState } from "react"; +import { useForm, SubmitHandler, Controller } from "react-hook-form"; +import { + Button, + Card, + ToggleSwitch, + FloatingLabel, + HelperText, + Spinner, + Tabs, + Toast, + ToastToggle, + TabItem, // Added TabItem import + Modal, // Import Modal + ModalBody, + ModalHeader, +} from "flowbite-react"; +// Import necessary icons +import { + HiUserCircle, + HiShieldCheck, + HiLockClosed, + HiCheck, + HiX, + HiTrash, // Import Trash icon for Delete + HiExclamation, // Import Exclamation icon for Modal +} from "react-icons/hi"; +import { + useUserProfile, + useUpdateUserProfile, + useDeleteUserProfile, // Import delete hook +} from "../api/hooks/useUserHooks"; +import { UserUpdate } from "../api/types/data-contracts"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { useAuth } from "../hooks/useAuth"; // Import useAuth for logout + +// Define the form data structure based on UserUpdate schema +type UserProfileFormData = { + username?: string; + phone?: string; + privacySettings_dataSharingConsent?: boolean; + privacySettings_anonymizeData?: boolean; +}; + +export default function UserProfilePage() { + const { data: userProfile, isLoading, error: fetchError } = useUserProfile(); + const { + mutate: updateUser, + isPending: isUpdating, + error: updateError, + isSuccess: isUpdateSuccess, // Rename for clarity + reset: resetUpdateMutation, + } = useUpdateUserProfile(); + const { + mutate: deleteUser, + isPending: isDeleting, + error: deleteError, + isSuccess: isDeleteSuccess, // Rename for clarity + reset: resetDeleteMutation, + } = useDeleteUserProfile(); + const { logout } = useAuth(); // Get logout function + + // State for toasts + const [showSuccessToast, setShowSuccessToast] = useState(false); + const [showErrorToast, setShowErrorToast] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + // State for delete confirmation modal + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const { + register, + handleSubmit, + reset, + control, + formState: { errors, isDirty }, + } = useForm({ + defaultValues: { + username: "", + phone: "", + privacySettings_dataSharingConsent: false, + privacySettings_anonymizeData: false, + }, + }); + + // Pre-fill form when userProfile data loads + useEffect(() => { + if (userProfile) { + reset({ + username: userProfile.username || "", + phone: userProfile.phone || "", + privacySettings_dataSharingConsent: + userProfile.privacySettings?.dataSharingConsent ?? false, + privacySettings_anonymizeData: + userProfile.privacySettings?.anonymizeData ?? false, + }); + } + }, [userProfile, reset]); + + // Show toasts based on UPDATE mutation state + useEffect(() => { + if (isUpdateSuccess) { + setShowSuccessToast(true); + setToastMessage("Profile updated successfully!"); + resetUpdateMutation(); + const timer = setTimeout(() => setShowSuccessToast(false), 5000); + return () => clearTimeout(timer); + } + }, [isUpdateSuccess, resetUpdateMutation]); + + useEffect(() => { + if (updateError) { + setShowErrorToast(true); + setToastMessage( + updateError.message || "Failed to update profile. Please try again.", + ); + resetUpdateMutation(); + const timer = setTimeout(() => setShowErrorToast(false), 5000); + return () => clearTimeout(timer); + } + }, [updateError, resetUpdateMutation]); + + // Show toasts/handle redirect based on DELETE mutation state + useEffect(() => { + if (isDeleteSuccess) { + setShowDeleteModal(false); // Close modal on success + // Don't show toast here, set flag for home page instead + // setShowSuccessToast(true); + // setToastMessage("User account deleted successfully."); + sessionStorage.setItem( + "showPostLogoutToast", + "User account deleted successfully.", + ); // <-- Set flag + resetDeleteMutation(); + logout(); // <-- Call logout directly + } + }, [isDeleteSuccess, resetDeleteMutation, logout]); // Keep dependencies + + useEffect(() => { + if (deleteError) { + // Keep modal open on error? Or close and show toast? Closing for now. + setShowDeleteModal(false); + setShowErrorToast(true); + setToastMessage( + deleteError.message || + "Failed to delete user account. Please try again.", + ); + resetDeleteMutation(); + const timer = setTimeout(() => setShowErrorToast(false), 5000); + return () => clearTimeout(timer); + } + }, [deleteError, resetDeleteMutation]); + + const onSubmit: SubmitHandler = (data) => { + const updatePayload: UserUpdate = { + username: data.username, + phone: data.phone, + privacySettings: { + dataSharingConsent: data.privacySettings_dataSharingConsent, + anonymizeData: data.privacySettings_anonymizeData, + }, + }; + updateUser(updatePayload); + }; + + // Function to handle Auth0 password reset redirection + const handlePasswordReset = () => { + const domain = import.meta.env.VITE_AUTH0_DOMAIN; + window.open(`https://${domain}/passwordreset`, "_blank"); // Open in new tab + }; + + // Function to handle user deletion + const handleDeleteUser = () => { + deleteUser(); // Call the mutation + }; + + if (isLoading) { + return ; + } + + if (fetchError) { + return ( + + ); + } + + return ( +
+ {/* Success Toast */} + {showSuccessToast && ( + +
+ +
+
{toastMessage}
+ setShowSuccessToast(false)} /> +
+ )} + + {/* Error Toast */} + {showErrorToast && ( + +
+ +
+
{toastMessage}
+ setShowErrorToast(false)} /> +
+ )} + + + {/* Use Tabs for organization */} + + {/* Profile Tab */} + +
+

+ Basic Information +

+ {/* Username */} +
+ + {errors.username?.message && ( + + {errors.username.message} + + )} +
+ + {/* Phone Number */} +
+ + + {errors.phone?.message || + "Enter phone number including country code (e.g., +1 for US)."} + +
+ {/* Save Button - Common for all tabs within the form */} + +
+
+ + {/* Privacy Tab */} + +
+

+ Privacy Settings +

+ {/* Data Sharing Consent */} + ( + + )} + /> + {/* Anonymize Data */} + ( + + )} + /> + {/* Save Button - Common for all tabs within the form */} + + +
+ + {/* Security Tab */} + +
+ {" "} + {/* Increased gap */} +

+ Security Settings +

+ {/* Password Reset */} +
+

+ Password +

+

+ Manage your account password via our authentication provider. +

+ +
+ {/* MFA */} +
+

+ Multi-Factor Authentication (MFA) +

+

+ Add an extra layer of security to your account. (Coming Soon) +

+ +
+ {/* Delete Account - ADDED */} +
+

+ Delete Account +

+

+ Permanently delete your user account and all associated data. + This action cannot be undone. +

+ +
+
+
+
+
+ + {/* Delete Confirmation Modal */} + !isDeleting && setShowDeleteModal(false)} // Prevent closing while deleting + popup + > + + +
+ +

+ Are you sure you want to permanently delete your user account? +

+

+ All associated data, including preferences and sharing settings, + will be lost. This action cannot be undone. +

+
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/pages/static/HomePage.tsx b/web/src/pages/static/HomePage.tsx index 556471f..9e58dc2 100644 --- a/web/src/pages/static/HomePage.tsx +++ b/web/src/pages/static/HomePage.tsx @@ -1,3 +1,6 @@ +import { useState, useEffect } from "react"; // <-- Import hooks +import { Toast, ToastToggle } from "flowbite-react"; // <-- Import Toast components +import { HiCheck } from "react-icons/hi"; // <-- Import icon import { DocsIcon, BlocksIcon, @@ -6,6 +9,23 @@ import { } from "../../components/icons/ResourceIcons"; export default function HomePage() { + const [showToast, setShowToast] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + + // Check for post-logout toast message on mount + useEffect(() => { + const message = sessionStorage.getItem("showPostLogoutToast"); + if (message) { + setToastMessage(message); + setShowToast(true); + sessionStorage.removeItem("showPostLogoutToast"); // Clear the flag + + // Optional: Auto-hide toast after a delay + const timer = setTimeout(() => setShowToast(false), 5000); + return () => clearTimeout(timer); // Cleanup timer on unmount + } + }, []); // Empty dependency array ensures this runs only once on mount + const CARDS = [ { title: "Flowbite React Docs", @@ -51,10 +71,20 @@ export default function HomePage() { }, ]; - // Removed the outer
tag and DarkThemeToggle from original App.tsx - // Added container and padding for spacing within the Layout's main area return ( -
+ // Add relative positioning if needed for absolute toast +
+ {/* Success Toast */} + {showToast && ( + +
+ +
+
{toastMessage}
+ setShowToast(false)} /> +
+ )} + {/* Background pattern - kept for visual style */}