Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/codebattle/assets/js/__tests__/UserSettings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const preloadedState = {
canUnlinkSocial: false,
discordName: null,
discordId: null,
hasPassword: false,
error: "",
},
},
Expand All @@ -60,6 +61,19 @@ const store = configureStore({
reducer,
preloadedState,
});
const buildStore = (settings = {}) =>
configureStore({
reducer,
preloadedState: {
user: {
settings: {
...preloadedState.user.settings,
...settings,
},
},
},
});

jest.mock(
"gon",
() => {
Expand Down Expand Up @@ -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(
<Provider store={customStore}>
<UserSettings />
</Provider>,
);

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(
<Provider store={customStore}>
<UserSettings />
</Provider>,
);

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,
Expand Down
77 changes: 71 additions & 6 deletions apps/codebattle/assets/js/widgets/pages/settings/UserSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;

Expand Down Expand Up @@ -103,21 +151,38 @@ 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) {
setNotification(notifications.error);
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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<Formik
Expand All @@ -195,7 +234,14 @@ function UserSettingsForm({ onSubmit, settings }) {
enableReinitialize
validateOnChange
validationSchema={validationSchema}
onSubmit={onSubmit}
onSubmit={(values, helpers) =>
onSubmit(values, {
...helpers,
settingsDirty:
JSON.stringify(omit(values, passwordFieldNames)) !==
JSON.stringify(omit(initialValues, passwordFieldNames)),
})
}
>
{({ handleChange, dirty, isValid, isSubmitting, values }) => (
<Form>
Expand Down Expand Up @@ -252,6 +298,42 @@ function UserSettingsForm({ onSubmit, settings }) {
</div>
</div>

{showPasswordFields && (
<div className="container">
<div className="row form-group mb-3">
<div className="col-lg-3">
<TextInput
data-testid="currentPasswordInput"
label="Old password"
id="currentPassword"
name="currentPassword"
type="password"
autoComplete="current-password"
placeholder="Enter old password"
/>
<TextInput
data-testid="passwordInput"
label="New password"
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder="Enter new password"
/>
<TextInput
data-testid="passwordConfirmationInput"
label="Confirm new password"
id="passwordConfirmation"
name="passwordConfirmation"
type="password"
autoComplete="new-password"
placeholder="Confirm new password"
/>
</div>
</div>
</div>
)}

<div id="my-radio-group" className="h6 ml-2">
Select sound type
</div>
Expand Down
Loading
Loading