Skip to content

Commit a637c4e

Browse files
authored
Merge pull request #1100 from Bonymol-aot/FWF-5971-FWF-5985/reset-password-integration/unsaved-changes-warning
FWF-5971 : [Feature] Added reset password integration, FWF-5985:[Feature] Added unsaved changes prompt
2 parents 0406afc + f13fb24 commit a637c4e

File tree

4 files changed

+217
-25
lines changed

4 files changed

+217
-25
lines changed

forms-flow-nav/src/endpoints/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { WEB_BASE_URL, MT_ADMIN_BASE_URL, MT_ADMIN_BASE_URL_VERSION } from "./co
22

33
const API = {
44
LANG_UPDATE: `${WEB_BASE_URL}/user/locale`,
5+
RESET_PASSWORD: (userId) => `${WEB_BASE_URL}/user/${userId}/reset-password`,
56
GET_TENANT_DATA: `${MT_ADMIN_BASE_URL}/${MT_ADMIN_BASE_URL_VERSION}/tenant`,
67
INTEGRATION_ENABLE_DETAILS: `${WEB_BASE_URL}/integrations/embed/display`
78
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { RequestService, StorageService } from "@formsflow/service";
2+
import API from "../../endpoints/index";
3+
import { WEB_BASE_CUSTOM_URL } from "../../constants/constants";
4+
5+
/**
6+
* Trigger a reset password email/link for the current user.
7+
*/
8+
const extractUserIdFromToken = (token) => {
9+
if (!token) return "";
10+
const raw = String(token).replace(/^Bearer\s+/i, "").trim();
11+
const parts = raw.split(".");
12+
if (parts.length < 2) return "";
13+
try {
14+
const base64Url = parts[1];
15+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
16+
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
17+
const atobFn = typeof atob === "function" ? atob : (typeof window !== "undefined" ? window.atob : null);
18+
if (!atobFn) return "";
19+
const json = JSON.parse(atobFn(padded));
20+
return json?.sub || "";
21+
} catch (e) {
22+
return "";
23+
}
24+
};
25+
26+
export const requestResetPassword = () => {
27+
const token = StorageService.get(StorageService.User.AUTH_TOKEN);
28+
const userId = extractUserIdFromToken(token);
29+
if (!userId) {
30+
return Promise.reject(new Error("User id not found in token"));
31+
}
32+
const redirectUri =
33+
(WEB_BASE_CUSTOM_URL && String(WEB_BASE_CUSTOM_URL).trim()) ||
34+
(typeof window !== "undefined" ? window.location.origin : "");
35+
const urlWithRedirect = `${API.RESET_PASSWORD(userId)}?redirect_uri=${encodeURIComponent(
36+
redirectUri
37+
)}`;
38+
return RequestService.httpPUTRequest(
39+
urlWithRedirect,
40+
{},
41+
token
42+
);
43+
};
44+
45+

forms-flow-nav/src/sidenav/ProfileSettingsModal.jsx

Lines changed: 155 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import React, { useState, useEffect } from 'react';
22
import PropTypes from 'prop-types';
33
import Modal from 'react-bootstrap/Modal';
44
import { Tabs, Tab } from 'react-bootstrap';
5-
import { CloseIcon, V8CustomButton, CustomInfo, SelectDropdown, CustomTextInput, ApplicationLogo } from "@formsflow/components";
6-
import { fetchSelectLanguages, updateUserlang } from '../services/language';
5+
import { CloseIcon, V8CustomButton, CustomInfo, SelectDropdown, CustomTextInput, ApplicationLogo, PromptModal } from "@formsflow/components";
6+
import { fetchSelectLanguages } from '../services/language';
7+
import { requestResetPassword } from "../services/user";
78
import { useTranslation } from "react-i18next";
89
import i18n from '../resourceBundles/i18n';
910
import { StorageService } from "@formsflow/service";
10-
import { KEYCLOAK_AUTH_URL, KEYCLOAK_REALM, LANGUAGE, MULTITENANCY_ENABLED, USER_LANGUAGE_LIST } from '../constants/constants';
11+
import { LANGUAGE, MULTITENANCY_ENABLED, USER_LANGUAGE_LIST } from '../constants/constants';
1112

1213
export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
1314
const [selectLanguages, setSelectLanguages] = useState([]);
@@ -16,18 +17,30 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
1617
const [daysDifference, setDaysDifference] = useState(null);
1718
const [activeTab, setActiveTab] = useState("Profile");
1819
const isSSO = false;
20+
const [showUnsavedChangesPrompt, setShowUnsavedChangesPrompt] = useState(false);
1921
const [profileFields, setProfileFields] = useState({
2022
firstName: "",
2123
lastName: "",
2224
email: "",
2325
username: "",
2426
});
2527
const [initialProfileFields, setInitialProfileFields] = useState(null);
28+
const [initialSelectedLang, setInitialSelectedLang] = useState(prevSelectedLang || LANGUAGE);
29+
const [emailTouched, setEmailTouched] = useState(false);
30+
const [usernameTouched, setUsernameTouched] = useState(false);
31+
const [resetPasswordState, setResetPasswordState] = useState("default"); // default | success | error
32+
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
33+
const [lastResetPasswordError, setLastResetPasswordError] = useState(null);
2634
const { t } = useTranslation();
2735

2836
useEffect(() => {
2937
if (!show) return;
3038
try {
39+
// Reset language selection to current app language when modal opens
40+
const currentLang = localStorage.getItem("i18nextLng") || LANGUAGE;
41+
setSelectedLang(currentLang);
42+
setInitialSelectedLang(currentLang);
43+
3144
const userDetail = JSON.parse(StorageService.get(StorageService.User.USER_DETAILS)) || {};
3245
const fullName = userDetail?.name || "";
3346
const [firstFromName = "", ...rest] = String(fullName).trim().split(/\s+/);
@@ -41,9 +54,16 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
4154
};
4255
setProfileFields(nextFields);
4356
setInitialProfileFields(nextFields);
57+
setEmailTouched(false);
58+
setUsernameTouched(false);
59+
setResetPasswordState("default");
60+
setResetPasswordLoading(false);
61+
setLastResetPasswordError(null);
4462
} catch (e) {
4563
setProfileFields({ firstName: "", lastName: "", email: "", username: "" });
4664
setInitialProfileFields({ firstName: "", lastName: "", email: "", username: "" });
65+
setSelectedLang(prevSelectedLang || LANGUAGE);
66+
setInitialSelectedLang(prevSelectedLang || LANGUAGE);
4767
}
4868
}, [show]);
4969

@@ -87,6 +107,52 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
87107
setSelectedLang(newLang);
88108
};
89109

110+
const isValidEmail = (email) => {
111+
// Simple, practical email validation (good UX, not overly strict)
112+
const value = String(email || "").trim();
113+
if (!value) return true; // allow empty if your system permits it
114+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
115+
};
116+
const emailIsInvalid = emailTouched && !isValidEmail(profileFields.email);
117+
118+
const isValidUsername = (username) => {
119+
const value = String(username || "");
120+
if (!value) return true; // allow empty if your system permits it
121+
return /^\S+$/.test(value); // no whitespace
122+
};
123+
const usernameIsInvalid =
124+
usernameTouched && !isValidUsername(profileFields.username);
125+
126+
useEffect(() => {
127+
setResetPasswordState("default");
128+
}, [profileFields.email]);
129+
130+
const handleResetPassword = async () => {
131+
setEmailTouched(true);
132+
if (!profileFields.email || emailIsInvalid) return;
133+
134+
setResetPasswordLoading(true);
135+
try {
136+
await requestResetPassword();
137+
setResetPasswordState("success");
138+
setLastResetPasswordError(null);
139+
} catch (e) {
140+
setResetPasswordState("error");
141+
const status = e?.response?.status;
142+
const message = e?.response?.data?.message || e?.message;
143+
const details = { status, message, data: e?.response?.data };
144+
setLastResetPasswordError(details);
145+
try {
146+
StorageService.save("PROFILE_RESET_PASSWORD_LAST_ERROR", JSON.stringify(details));
147+
} catch (_) {
148+
}
149+
150+
console.error("Reset password failed:", details);
151+
} finally {
152+
setResetPasswordLoading(false);
153+
}
154+
};
155+
90156
const handleConfirmProfile = () => {
91157
// Keep a copy for later integration; for now just save it locally and close the modal.
92158
const firstName = (profileFields.firstName || "").trim();
@@ -121,7 +187,32 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
121187
profileFields.lastName !== initialProfileFields.lastName ||
122188
profileFields.email !== initialProfileFields.email ||
123189
profileFields.username !== initialProfileFields.username);
124-
const isAnythingChanged = isProfileChanged || !isSaveDisabled;
190+
const isLangChanged = selectedLang !== initialSelectedLang;
191+
const isAnythingChanged = isProfileChanged || isLangChanged;
192+
193+
const handleRequestClose = () => {
194+
if (activeTab === "Profile" && isAnythingChanged) {
195+
setShowUnsavedChangesPrompt(true);
196+
return;
197+
}
198+
onClose();
199+
};
200+
201+
const handleDiscardAndClose = () => {
202+
setShowUnsavedChangesPrompt(false);
203+
if (initialProfileFields) setProfileFields(initialProfileFields);
204+
setSelectedLang(initialSelectedLang);
205+
setEmailTouched(false);
206+
setUsernameTouched(false);
207+
setResetPasswordState("default");
208+
setLastResetPasswordError(null);
209+
onClose();
210+
};
211+
212+
const handleSaveAndClose = () => {
213+
setShowUnsavedChangesPrompt(false);
214+
handleConfirmProfile();
215+
};
125216

126217
const selectedLangLabel = selectLanguages.find(lang => lang.name === selectedLang)?.value || selectedLang;
127218

@@ -130,18 +221,14 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
130221
{ key: "Permissions", label: t("Permissions") },
131222
];
132223

133-
const resetPasswordUrl =
134-
KEYCLOAK_AUTH_URL && KEYCLOAK_REALM
135-
? `${KEYCLOAK_AUTH_URL}/realms/${KEYCLOAK_REALM}/account`
136-
: null;
137-
138224
// Get tenantId from tenant prop or StorageService
139225
const tenantId = tenant?.tenantId || StorageService.get("tenantKey");
140226

141227
return (
228+
<>
142229
<Modal
143230
show={show}
144-
onHide={onClose}
231+
onHide={handleRequestClose}
145232
size="lg"
146233
dialogClassName="profile-settings-modal"
147234
data-testid="profile-settings-modal"
@@ -153,7 +240,7 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
153240
<div className="modal-header-content">
154241
<div className="modal-title pb-0">
155242
<p>{t("Personal Settings")}</p>
156-
<CloseIcon color="var(--gray-darkest)" onClick={onClose}/>
243+
<CloseIcon color="var(--gray-darkest)" onClick={handleRequestClose}/>
157244
</div>
158245
<div className="modal-subtitle pb-0">
159246
<div className='secondary-controls'>
@@ -223,7 +310,14 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
223310
dataTestId="profile-email"
224311
ariaLabel={t("Email")}
225312
disabled={isSSO}
313+
onBlur={() => setEmailTouched(true)}
226314
/>
315+
<div
316+
className="profile-settings-field-error text-danger mt-1"
317+
data-testid="profile-email-error"
318+
>
319+
{emailIsInvalid ? t("Email address is invalid") : ""}
320+
</div>
227321
</div>
228322
<div className="col-12 col-md-6">
229323
<div className="input-label-text">{t("Username")}</div>
@@ -234,27 +328,50 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
234328
dataTestId="profile-username"
235329
ariaLabel={t("Username")}
236330
disabled={isSSO}
331+
onBlur={() => setUsernameTouched(true)}
237332
/>
333+
<div
334+
className="profile-settings-field-error text-danger mt-1"
335+
data-testid="profile-username-error"
336+
>
337+
{usernameIsInvalid ? t("Username is invalid") : ""}
338+
</div>
238339
</div>
239340
<div className="col-12">
240341
<V8CustomButton
241-
label={t("Reset Password")}
342+
label={resetPasswordLoading ? "Resetting password" : "Reset Password"}
242343
variant="secondary"
243344
dataTestId="profile-reset-password"
244-
ariaLabel={t("Reset Password")}
245-
disabled={!resetPasswordUrl}
246-
onClick={() => {
247-
if (!resetPasswordUrl) return;
248-
window.open(resetPasswordUrl, "_blank", "noopener,noreferrer");
249-
}}
345+
ariaLabel="Reset Password"
346+
disabled={
347+
isSSO ||
348+
resetPasswordLoading ||
349+
!profileFields.email ||
350+
emailIsInvalid
351+
}
352+
loading={resetPasswordLoading}
353+
onClick={handleResetPassword}
250354
/>
251355
</div>
252356
<div className="col-12">
253357
<CustomInfo
254-
className="profile-settings-note-panel"
358+
className={[
359+
"profile-settings-note-panel",
360+
resetPasswordState === "error"
361+
? "profile-settings-note-panel--danger-text"
362+
: "",
363+
].join(" ")}
255364
variant="secondary"
256365
icon={<ApplicationLogo width="1.1875rem" height="1.4993rem" />}
257-
content={t("Success! Check your email inbox for next steps.")}
366+
content={
367+
resetPasswordState === "success"
368+
? t("Success! Check your email inbox for next steps.")
369+
: resetPasswordState === "error"
370+
? t("Uh-oh! Something went wrong. Please try again.")
371+
: t("Resetting your password sends a reset link to {{email}}.", {
372+
email: profileFields.email || "",
373+
})
374+
}
258375
/>
259376
</div>
260377
</div>
@@ -296,13 +413,30 @@ export const ProfileSettingsModal = ({ show, onClose, tenant, publish }) => {
296413
onClick={handleConfirmProfile}
297414
dataTestId="save-profile-settings"
298415
ariaLabel={t("Save Profile Settings")}
299-
disabled={activeTab !== "Profile" || !isAnythingChanged}
416+
disabled={activeTab !== "Profile" || !isAnythingChanged || emailIsInvalid || usernameIsInvalid}
300417
variant="primary"
301418
/>
302419

303420
</div>
304421
</Modal.Footer>
305422
</Modal>
423+
424+
<PromptModal
425+
show={showUnsavedChangesPrompt}
426+
size="sm"
427+
onClose={() => setShowUnsavedChangesPrompt(false)}
428+
type="warning"
429+
title={t("You have unsaved changes")}
430+
message={t("Leaving will discard any unsaved changes. Are you sure you want to continue?")}
431+
primaryBtnText={t("Save")}
432+
secondaryBtnText={t("Discard Changes")}
433+
primaryBtnAction={handleSaveAndClose}
434+
secondaryBtnAction={handleDiscardAndClose}
435+
primaryBtnDisable={emailIsInvalid || usernameIsInvalid}
436+
primaryBtndataTestid="profile-settings-save-before-close"
437+
secondoryBtndataTestid="profile-settings-discard-before-close"
438+
/>
439+
</>
306440
);
307441
};
308442

forms-flow-theme/scss/v8-scss/_modal.scss

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -637,12 +637,18 @@ $base: 1rem; // Cannot use var() in SCSS arithmetic
637637
overflow: auto;
638638
}
639639

640+
.profile-settings-field-error {
641+
width: 100%;
642+
min-height: 1.25rem;
643+
overflow-wrap: anywhere;
644+
}
645+
640646
.profile-settings-note-panel {
641-
width: 46rem;
642-
height: 3.688rem;
643-
max-width: 100%;
647+
width: 100%;
648+
max-width: 46rem;
649+
min-height: 3.688rem;
644650
box-sizing: border-box;
645-
overflow: hidden;
651+
overflow: visible;
646652
}
647653

648654
.profile-settings-note-panel.info-panel {
@@ -664,4 +670,10 @@ $base: 1rem; // Cannot use var() in SCSS arithmetic
664670
align-items: center;
665671
}
666672
}
673+
674+
.profile-settings-note-panel--danger-text {
675+
.info-content {
676+
color: var(--default-danger-color);
677+
}
678+
}
667679
}

0 commit comments

Comments
 (0)