diff --git a/apps/codebattle/assets/js/__tests__/UserSettings.test.jsx b/apps/codebattle/assets/js/__tests__/UserSettings.test.jsx
index e9f19a211..c820d2757 100644
--- a/apps/codebattle/assets/js/__tests__/UserSettings.test.jsx
+++ b/apps/codebattle/assets/js/__tests__/UserSettings.test.jsx
@@ -52,6 +52,7 @@ const preloadedState = {
canUnlinkSocial: false,
discordName: null,
discordId: null,
+ hasPassword: false,
error: "",
},
},
@@ -60,6 +61,19 @@ const store = configureStore({
reducer,
preloadedState,
});
+const buildStore = (settings = {}) =>
+ configureStore({
+ reducer,
+ preloadedState: {
+ user: {
+ settings: {
+ ...preloadedState.user.settings,
+ ...settings,
+ },
+ },
+ },
+ });
+
jest.mock(
"gon",
() => {
@@ -218,6 +232,41 @@ describe("UserSettings test cases", () => {
expect(await findByRole("alert")).toHaveClass("alert-danger");
});
+ test("hides password fields for Firebase user without local password", () => {
+ const customStore = buildStore({
+ firebaseUid: "firebase-user",
+ hasPassword: false,
+ });
+
+ const { queryByTestId } = setup(
+
+
+ ,
+ );
+
+ expect(queryByTestId("currentPasswordInput")).not.toBeInTheDocument();
+ expect(queryByTestId("passwordInput")).not.toBeInTheDocument();
+ expect(queryByTestId("passwordConfirmationInput")).not.toBeInTheDocument();
+ });
+
+ test("shows password fields for local password user with linked OAuth", () => {
+ const customStore = buildStore({
+ githubId: 19,
+ githubName: "octocat",
+ hasPassword: true,
+ });
+
+ const { getByTestId } = setup(
+
+
+ ,
+ );
+
+ expect(getByTestId("currentPasswordInput")).toBeInTheDocument();
+ expect(getByTestId("passwordInput")).toBeInTheDocument();
+ expect(getByTestId("passwordConfirmationInput")).toBeInTheDocument();
+ });
+
test("unlink button is disabled when it is the last sign-in method", () => {
const customStore = configureStore({
reducer,
diff --git a/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.jsx b/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.jsx
index e91f13a94..47871944e 100644
--- a/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.jsx
+++ b/apps/codebattle/assets/js/widgets/pages/settings/UserSettings.jsx
@@ -24,6 +24,11 @@ const notifications = {
error: { variant: "danger", message: i18n.t("Something went wrong") },
empty: {},
};
+const emptyPasswordValues = {
+ currentPassword: "",
+ password: "",
+ passwordConfirmation: "",
+};
const csrfToken = document?.querySelector("meta[name='csrf-token']")?.getAttribute("content");
const updateSettings = async (values) => {
@@ -46,6 +51,49 @@ const updateSettings = async (values) => {
return data;
};
+const updatePassword = async (values) => {
+ const response = await fetch("/api/v1/settings/password", {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "x-csrf-token": csrfToken,
+ },
+ body: JSON.stringify(decamelizeKeys(values)),
+ });
+ const data = await response.json();
+
+ if (!response.ok) {
+ const error = new Error(`Request failed with status ${response.status}`);
+ error.response = { data, status: response.status };
+ throw error;
+ }
+
+ return data;
+};
+
+const getPasswordValues = ({ currentPassword, password, passwordConfirmation }) => ({
+ currentPassword,
+ password,
+ passwordConfirmation,
+});
+
+const getSettingsValues = ({
+ currentPassword,
+ password,
+ passwordConfirmation,
+ ...settingsValues
+}) => settingsValues;
+
+const hasPasswordChanges = (values) =>
+ Object.values(values).some((value) => String(value || "").trim() !== "");
+
+const formatFieldErrors = (errors = {}) =>
+ Object.entries(camelizeKeys(errors)).reduce((acc, [fieldName, messages]) => {
+ const fieldMessages = Array.isArray(messages) ? messages : [messages];
+ acc[fieldName] = fieldMessages.map(capitalize).join(", ");
+ return acc;
+ }, {});
+
function Notification({ notification, onClose }) {
const { variant, message } = notification;
@@ -103,12 +151,23 @@ function UserSettings() {
const dispatch = useDispatch();
const handleUpdateUserSettings = useCallback(
- async (values, { setErrors }) => {
+ async (values, { setErrors, resetForm, settingsDirty }) => {
+ const passwordValues = getPasswordValues(values);
+ const settingsValues = getSettingsValues(values);
+
try {
- const data = await updateSettings(values);
+ if (settingsDirty) {
+ const data = await updateSettings(settingsValues);
+
+ await i18n.changeLanguage(getSupportedLocale(data.locale));
+ dispatch(actions.updateUserSettings(camelizeKeys(data)));
+ }
- await i18n.changeLanguage(getSupportedLocale(data.locale));
- dispatch(actions.updateUserSettings(camelizeKeys(data)));
+ if (hasPasswordChanges(passwordValues)) {
+ await updatePassword(passwordValues);
+ }
+
+ resetForm({ values: { ...settingsValues, ...emptyPasswordValues } });
setNotification(notifications.success);
} catch (error) {
if (!error.response) {
@@ -116,8 +175,14 @@ function UserSettings() {
return;
}
- const { name: userNameErrors = [] } = error.response.data.errors;
- setErrors({ name: userNameErrors.map(capitalize).join(", ") });
+ const fieldErrors = formatFieldErrors(error.response.data.errors);
+
+ if (Object.keys(fieldErrors).length === 0) {
+ setNotification(notifications.error);
+ return;
+ }
+
+ setErrors(fieldErrors);
}
},
[dispatch],
diff --git a/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.jsx b/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.jsx
index fbc4244b7..8e23fc579 100644
--- a/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.jsx
+++ b/apps/codebattle/assets/js/widgets/pages/settings/UserSettingsForm.jsx
@@ -21,6 +21,7 @@ const views = {
sql: "sql",
};
+const passwordFieldNames = ["currentPassword", "password", "passwordConfirmation"];
const playingLanguages = Object.entries(omit(languages, [...cssProcessors, ...dbNames]));
const cssLanguages = Object.entries(pick(languages, cssProcessors));
const databaseTypes = Object.entries(pick(languages, dbNames));
@@ -53,6 +54,37 @@ const getPlaceholder = ({ disabled, placeholder }) => {
return "No access yet";
};
+const hasPasswordValue = (values) =>
+ passwordFieldNames.some((fieldName) => String(values[fieldName] || "").trim() !== "");
+
+const passwordValidationSchema = {
+ currentPassword: Yup.string().test(
+ "required-current-password",
+ "Field can't be empty",
+ function (value) {
+ return !hasPasswordValue(this.parent) || String(value || "").trim() !== "";
+ },
+ ),
+ password: Yup.string()
+ .test("required-password", "Field can't be empty", function (value) {
+ return !hasPasswordValue(this.parent) || String(value || "").trim() !== "";
+ })
+ .test("password-min-length", "Should be at least 6 characters", function (value) {
+ return !hasPasswordValue(this.parent) || String(value || "").length >= 6;
+ }),
+ passwordConfirmation: Yup.string()
+ .test("required-password-confirmation", "Field can't be empty", function (value) {
+ return !hasPasswordValue(this.parent) || String(value || "").trim() !== "";
+ })
+ .test("password-confirmation", "Passwords must match", function (value) {
+ return !hasPasswordValue(this.parent) || value === this.parent.password;
+ }),
+};
+
+const canChangePassword = (settings) => {
+ return settings.hasPassword === true;
+};
+
function TextInput({ label, ...props }) {
const [field, meta] = useField(props);
const { name, disabled, hint, hintHref = "", ...inputProps } = props;
@@ -182,11 +214,18 @@ function UserSettingsForm({ onSubmit, settings }) {
lang: settings.lang || "",
styleLang: settings.styleLang || "",
dbType: settings.dbType || "",
+ currentPassword: "",
+ password: "",
+ passwordConfirmation: "",
}),
[settings],
);
- const validationSchema = useMemo(() => Yup.object(schemas.userSettings(settings)), [settings]);
+ const validationSchema = useMemo(
+ () => Yup.object({ ...schemas.userSettings(settings), ...passwordValidationSchema }),
+ [settings],
+ );
+ const showPasswordFields = canChangePassword(settings);
return (
+ onSubmit(values, {
+ ...helpers,
+ settingsDirty:
+ JSON.stringify(omit(values, passwordFieldNames)) !==
+ JSON.stringify(omit(initialValues, passwordFieldNames)),
+ })
+ }
>
{({ handleChange, dirty, isValid, isSubmitting, values }) => (