diff --git a/src/components/Widgets/FeedbackWidget/context.js b/src/components/Widgets/FeedbackWidget/context.js index 6f9bec36d..b15703d24 100644 --- a/src/components/Widgets/FeedbackWidget/context.js +++ b/src/components/Widgets/FeedbackWidget/context.js @@ -12,7 +12,7 @@ export function FeedbackProvider({ page, hideHeader, test = {}, ...props }) { const [view, setView] = useState(test.view || 'waiting'); const [screenshotTaken, setScreenshotTaken] = useState(test.screenshotTaken || false); const [progress, setProgress] = useState([true, false, false]); - const user = useRealmUser(); + const { user, reassignCurrentUser } = useRealmUser(); // Create a new feedback document const initializeFeedback = (nextView = 'rating') => { @@ -40,6 +40,17 @@ export function FeedbackProvider({ page, hideHeader, test = {}, ...props }) { } }; + const retryFeedbackSubmission = async (newFeedback) => { + try { + const newUser = await reassignCurrentUser(); + newFeedback.user.stitch_id = newUser.id; + await createNewFeedback(newFeedback); + setFeedback(newFeedback); + } catch (e) { + console.error('Error when retrying feedback submission', e); + } + }; + const submitAllFeedback = async ({ comment = '', email = '', snootyEnv, dataUri, viewport }) => { // Route the user to their "next steps" @@ -75,7 +86,13 @@ export function FeedbackProvider({ page, hideHeader, test = {}, ...props }) { await createNewFeedback(newFeedback); setFeedback(newFeedback); } catch (err) { + // This catch block will most likely only be hit after Realm attempts internal retry logic + // after access token is refreshed console.error('There was an error submitting feedback', err); + if (err.statusCode === 401) { + // Explicitly retry 1 time to avoid any infinite loop + await retryFeedbackSubmission(newFeedback); + } } }; diff --git a/src/components/Widgets/FeedbackWidget/realm.js b/src/components/Widgets/FeedbackWidget/realm.js index 912011198..4c058d840 100644 --- a/src/components/Widgets/FeedbackWidget/realm.js +++ b/src/components/Widgets/FeedbackWidget/realm.js @@ -5,15 +5,43 @@ import { isBrowser } from '../../../utils/is-browser'; const APP_ID = 'feedbackwidgetv3-dgcsv'; export const app = isBrowser ? Realm.App.getApp(APP_ID) : { auth: {} }; +/** + * @param {object} storage + * @returns The prefix for the storage key used by Realm for localStorage access + */ +function parseStorageKey(storage) { + if (!storage.keyPart) { + return ''; + } + const prefix = parseStorageKey(storage.storage); + return prefix + storage.keyPart + ':'; +} + +/** + * Deletes localStorage data for all users + */ +function deleteLocalStorageData() { + const { allUsers } = app; + // The accessToken and refreshToken are automatically removed if invalid, but not the following keys + const keysToDelete = ['profile', 'providerType']; + + Object.values(allUsers).forEach((user) => { + const storageKeyPrefix = parseStorageKey(user.storage); + keysToDelete.forEach((key) => { + localStorage.removeItem(storageKeyPrefix + key); + }); + }); +} + // User Authentication & Management export async function loginAnonymous() { if (!app.currentUser) { const user = await app.logIn(Realm.Credentials.anonymous()); return user; - } else { - return app.currentUser; } + return app.currentUser; } + export async function logout() { if (app.auth.isLoggedIn) { await app.auth.logoutUserWithId(app.id); @@ -21,12 +49,37 @@ export async function logout() { console.warn('No logged in user.'); } } + export const useRealmUser = () => { const [user, setUser] = React.useState(app.currentUser); + + async function reassignCurrentUser() { + const oldUser = app.currentUser; + try { + await app.removeUser(oldUser); + } catch (e) { + console.error(e); + } + + // Clean up invalid data from local storage to avoid bubbling up local storage sizes for broken user credentials + // This should be safe since only old users' data would be deleted, and we make a new user right after + deleteLocalStorageData(); + + const newUser = await app.logIn(Realm.Credentials.anonymous()); + setUser(newUser); + return newUser; + } + + // Initial user login React.useEffect(() => { - loginAnonymous().then(setUser); + loginAnonymous() + .then(setUser) + .catch((e) => { + console.error(e); + }); }, []); - return user; + + return { user, reassignCurrentUser }; }; // Feedback Widget Functions diff --git a/tests/unit/FeedbackWidget.test.js b/tests/unit/FeedbackWidget.test.js index 861e84caf..000c8292a 100644 --- a/tests/unit/FeedbackWidget.test.js +++ b/tests/unit/FeedbackWidget.test.js @@ -257,6 +257,23 @@ describe('FeedbackWidget', () => { await tick(); expect(wrapper.getByText(EMAIL_ERROR_TEXT)).toBeTruthy(); }); + + it('attempts to resubmit on 401 error', async () => { + const customError = new Error('mock error message'); + customError.statusCode = 401; + stitchFunctionMocks['createNewFeedback'].mockRejectedValueOnce(customError); + + wrapper = await mountFormWithFeedbackState({ + view: 'comment', + comment: 'This is a test comment.', + sentiment: 'Positive', + rating: 5, + user: { email: 'test@example.com' }, + }); + userEvent.click(wrapper.getByText(FEEDBACK_SUBMIT_BUTTON_TEXT).closest('button')); + await tick(); + expect(stitchFunctionMocks['createNewFeedback']).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/tests/utils/feedbackWidgetStitchFunctions.js b/tests/utils/feedbackWidgetStitchFunctions.js index 75a774285..c4ac0baba 100644 --- a/tests/utils/feedbackWidgetStitchFunctions.js +++ b/tests/utils/feedbackWidgetStitchFunctions.js @@ -16,7 +16,11 @@ export function mockStitchFunctions() { stitchFunctionMocks['useRealmUser'] = jest.spyOn(realm, 'useRealmUser').mockImplementation(() => { return { - id: 'test-user-id', + user: { + id: 'test-user-id', + }, + // Most of this logic is dependent on Realm app working + reassignCurrentUser: () => ({ id: 'another-test-user-id' }), }; }); }