- <% unless @user.confirmed_at %>
- <%= alert :warning, t('.unconfirmed_email', email: @user.email) %>
- <% end %>
- <% if @user.unconfirmed_wca_id.present? %>
- <% mail_to_delegate = mail_to(@user.delegate_to_handle_wca_id_claim.email, @user.delegate_to_handle_wca_id_claim.name, target: "_blank") %>
- <% link_id = wca_id_link(@user.unconfirmed_wca_id) %>
- <% message = @user.confirmed_at ? '.pending_claim_confirmation_html' : '.pending_claim_mail_confirmation_html' %>
- <% # i18n-tasks-use t('.pending_claim_confirmation_html') #<- useful because the previous line tricks i18n-tasks %>
- <% # i18n-tasks-use t('.pending_claim_mail_confirmation_html') %>
- <%= alert :warning, t(message, delegate: mail_to_delegate, wca_id: link_id) %>
- <% end %>
- <% if @user.unconfirmed_email %>
- <%= alert :warning, t('.pending_mail_confirmation', email: @user.unconfirmed_email) %>
- <% end %>
- <% if @user.dummy_account? %>
- <%= alert :warning do %>
- This account is a dummy account. It serves as a placeholder because the competitor
- uploaded a profile picture before the website supported WCA accounts. This
- acount will be automatically deleted when another user is assigned WCA
- id <%= @user.wca_id %>.
- See <%= link_to "this commit", "https://github.com/thewca/worldcubeassociation.org/commit/32624f95b2c9e68967f8680ffa3ed7aefccd5319", target: "_blank" %> for more details.
- <% end %>
- <% end %>
- <% if @user.cannot_register_for_competition_reasons.length > 0 %>
- <%= alert :warning do %>
- <%= t '.please_fix_profile' %>
-
- <% end %>
- <% end %>
- <% if current_user.cannot_edit_data_reason_html(@user) %>
- <%= alert :warning, current_user.cannot_edit_data_reason_html(@user) %>
- <% end %>
-
- <% editable_fields = current_user.editable_fields_of_user(@user) %>
-
-
-
- <%= simple_form_for @user, html: { class: 'are-you-sure' } do |f| %>
- <%= hidden_field_tag "section", "general" %>
-
- <%= f.input :name, disabled: !editable_fields.include?(:name) %>
- <%= f.input :dob, as: :date_picker, disabled: !editable_fields.include?(:dob) %>
- <%= f.input :gender, disabled: !editable_fields.include?(:gender) %>
- <%= f.input :country_iso2, collection: Country.all_sorted_by(I18n.locale, real: true), value_method: lambda { |c| c.iso2 }, label_method: lambda { |c| c.name }, disabled: !editable_fields.include?(:country_iso2) %>
-
- <% if current_user.can_view_all_users? %>
-
-
- <% if @user.unconfirmed_wca_id.present? %>
-
- <% end %>
-
-
- <%= f.input :wca_id, as: :wca_id, disabled: !editable_fields.include?(:wca_id)%>
- <% if current_user.can_edit_any_user? && @user.is_special_account? %>
-
- <%= t '.account_is_special' %>
-
- <% end %>
-
-
- <% else %>
-
- <% if @user.wca_id.blank? %>
- <%= t '.have_no_wca_id_html', here: link_to(t('common.here'), profile_claim_wca_id_path) %>
- <% else %>
- <%= t '.have_wca_id_html', link_id: wca_id_link(@user.wca_id) %>
- <% end %>
-
- <% end %>
-
- <% if @user.staff_or_any_delegate? && (current_user.can_view_all_users? || current_user == @user) %>
- <%= f.input :receive_delegate_reports, disabled: !editable_fields.include?(:receive_delegate_reports) %>
- <%= f.input :delegate_reports_region, as: :grouped_select, collection: region_options_hash(real_only: true), group_method: :second, selected: @user.delegate_reports_region_id, disabled: !editable_fields.include?(:delegate_reports_region), include_blank: true %>
- <% end %>
-
- <%= f.submit t('.save'), class: "btn btn-primary" %>
- <% end %>
-
-
- <% if @user == current_user %>
- <%# The section below holds sensitivate data changes. For this we require %>
- <%# the user to enter again their password or 2FA. %>
- <% if @recently_authenticated %>
-
- <%= simple_form_for @user do |f| %>
- <%= hidden_field_tag "section", "email" %>
- <%= f.input :email, disabled: !editable_fields.include?(:email), hint: t('.confirm_new_email') %>
-
- <%= f.submit t('.save'), class: "btn btn-primary" %>
- <% end %>
-
-
- <%= simple_form_for @user do |f| %>
- <%= hidden_field_tag "section", "password" %>
- <%= f.input :password, autocomplete: "off", class: "form-control" %>
- <%= f.input :password_confirmation, class: "form-control" %>
-
- <%= f.submit t('.save'), class: "btn btn-primary" %>
- <% end %>
-
-
<%= t('.actions') %>
- <%= link_to(t('.sign_out_of_devices'), destroy_other_user_sessions_path,
- method: :delete, class: "btn btn-danger") %>
-
-
- <%= render "2fa_tab" %>
-
- <% else %>
-
- <%= render "2fa_confirm" %>
-
- <% end %>
-
- <%= simple_form_for @user do |f| %>
- <%= hidden_field_tag "section", "preferences" %>
-
- <%= render "shared/associated_events_picker", form_builder: f, disabled: !editable_fields.include?(:preferred_events),
- events_association_name: :user_preferred_events, selected_events: @user.preferred_events %>
-
<%= t '.preferred_events_desc' %>
-
- <%= label_tag :notifications, t('layouts.navigation.notifications') %>
-
- <%= f.input :results_notifications_enabled %>
- <%= f.input :registration_notifications_enabled %>
-
-
- <%= f.submit t('.save'), class: "btn btn-primary" %>
- <% end %>
-
- <% end %>
-
- <% if current_user.can_change_users_avatar?(@user) %>
-
- <%= react_component("EditAvatar", {
- userId: @user.id,
- showStaffGuidelines: @user.staff_or_any_delegate?,
- uploadDisabled: !editable_fields.include?(:pending_avatar),
- canRemoveAvatar: editable_fields.include?(:remove_avatar),
- canAdminAvatars: current_user.can_admin_results?,
- }) %>
-
- <% end %>
-
- <%= react_component("RolesTab", {
- userId: @user.id,
- }) %>
+<% current_user_json = current_user.as_json({ include: [], methods: %w[can_view_all_users? can_edit_any_user? can_admin_results?]})
+ current_user_json["cannot_edit_data_reason_html"] = current_user.cannot_edit_data_reason_html(@user)
+ current_user_json["can_change_users_avatar?"] = current_user.can_change_users_avatar?(@user)
+%>
+
+<% unless @recently_authenticated %>
+
+
+ <%= render "2fa_confirm" %>
-
-
+<% end %>
+
+<% otp_svg = raw(qrcode_for_user(@user).as_svg(
+ offset: 0,
+ color: '000',
+ shape_rendering: 'crispEdges',
+ module_size: 4
+)) if @user.otp_required_for_login %>
+
+<%= react_component("Persons/Edit", {
+ user: @user.as_json({
+ include: [],
+ only: [:email, :id, :wca_id, :unconfirmed_email],
+ methods: [
+ :confirmed_at,
+ :unconfirmed_wca_id,
+ :delegate_to_handle_wca_id_claim,
+ :dummy_account?,
+ :cannot_register_for_competition_reasons,
+ :is_special_account?,
+ :preferred_events,
+ :staff_or_any_delegate?,
+ :name, :dob, :gender, :country_iso2,
+ :receive_delegate_reports, :delegate_reports_region,
+ :results_notifications_enabled, :registration_notifications_enabled,
+ :pending_avatar, :otp_required_for_login
+ ]
+ }),
+ currentUser: current_user_json,
+ editableFields: current_user.editable_fields_of_user(@user),
+ recentlyAuthenticated: @recently_authenticated,
+ otpSVG: otp_svg,
+}) %>
diff --git a/app/webpacker/components/CountrySelector/CountrySelector.js b/app/webpacker/components/CountrySelector/CountrySelector.js
index 21a00b173a6..658b92b68fb 100644
--- a/app/webpacker/components/CountrySelector/CountrySelector.js
+++ b/app/webpacker/components/CountrySelector/CountrySelector.js
@@ -13,7 +13,7 @@ const countryOptions = countries.real.map((country) => ({
}));
function CountrySelector({
- name, countryIso2, onChange, error,
+ name, countryIso2, onChange, error, disabled,
}) {
return (
);
}
diff --git a/app/webpacker/components/GenderSelector/GenderSelector.js b/app/webpacker/components/GenderSelector/GenderSelector.js
index ffa09ea6d84..074373f1f38 100644
--- a/app/webpacker/components/GenderSelector/GenderSelector.js
+++ b/app/webpacker/components/GenderSelector/GenderSelector.js
@@ -10,13 +10,17 @@ const genderOptions = _.map(genders.byId, (gender) => ({
value: gender.id,
}));
-function GenderSelector({ gender, onChange }) {
+function GenderSelector({
+ gender, onChange, name, disabled,
+}) {
return (
);
}
diff --git a/app/webpacker/components/Persons/Edit/EmailChangeTab.jsx b/app/webpacker/components/Persons/Edit/EmailChangeTab.jsx
new file mode 100644
index 00000000000..37643a4e825
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/EmailChangeTab.jsx
@@ -0,0 +1,34 @@
+import React, { useEffect } from 'react';
+import { Form, Modal, Segment } from 'semantic-ui-react';
+import useInputState from '../../../lib/hooks/useInputState';
+import I18nHTMLTranslate from '../../I18nHTMLTranslate';
+import { updateUserUrl } from '../../../lib/requests/routes.js.erb';
+import RailsForm from './RailsForm';
+
+export default function EmailChangeTab({ user, recentlyAuthenticated }) {
+ const [email, setEmail] = useInputState(user.email);
+
+ // Hack to allow this with devise
+ useEffect(() => {
+ if (!recentlyAuthenticated) {
+ document.getElementById('2fa-check').style.display = 'block';
+ }
+ }, [recentlyAuthenticated]);
+
+ if (!recentlyAuthenticated) {
+ return
;
+ }
+
+ return (
+
+
+
+
+ {/* i18n-tasks-use t('users.edit.confirm_new_email') */}
+
+
+ Save
+
+
+ );
+}
diff --git a/app/webpacker/components/Persons/Edit/GeneralChangesTab.jsx b/app/webpacker/components/Persons/Edit/GeneralChangesTab.jsx
new file mode 100644
index 00000000000..4a8646528ef
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/GeneralChangesTab.jsx
@@ -0,0 +1,104 @@
+import React, { useState } from 'react';
+import {
+ Form, Segment, Message, ButtonGroup, Button,
+} from 'semantic-ui-react';
+import useInputState from '../../../lib/hooks/useInputState';
+import CountrySelector from '../../CountrySelector/CountrySelector';
+import I18nHTMLTranslate from '../../I18nHTMLTranslate';
+import { personUrl, profileClaimWCAIdUrl, updateUserUrl } from '../../../lib/requests/routes.js.erb';
+import I18n from '../../../lib/i18n';
+import './preferences.scss';
+import GenderSelector from '../../GenderSelector/GenderSelector';
+import RailsForm from './RailsForm';
+
+export default function GeneralChangesTab({ user, currentUser, editableFields }) {
+ const [name, setName] = useInputState(user.name);
+ const [dob, setDob] = useInputState(user.dob);
+ const [gender, setGender] = useInputState(user.gender);
+ const [countryIso2, setCountryIso2] = useState(user.country_iso2);
+ const [wcaId, setWcaId] = useInputState(user.wca_id);
+ const [unconfirmedWcaId, setUnconfirmedWcaId] = useInputState(user.unconfirmed_wca_id);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setCountryIso2(region))}
+ />
+
+ { currentUser['can_view_all_users?'] ? (
+ <>
+
+ {unconfirmedWcaId && (
+
+ )}
+
+
+
+ {I18n.t('users.edit.profile')}
+ {I18n.t('users.edit.approve')}
+
+ { currentUser['can_edit_any_user?'] && user['is_special_account?']
+ && (
+
+ {/* i18n-tasks-use t('users.edit.account_is_special') */}
+
+
+ )}
+ >
+ ) : (
+ ${I18n.t('common.here')}`,
+ link_id: `${wcaId} `,
+ }}
+ />
+ )}
+ Save
+
+
+ );
+}
diff --git a/app/webpacker/components/Persons/Edit/PasswordChangeTab.jsx b/app/webpacker/components/Persons/Edit/PasswordChangeTab.jsx
new file mode 100644
index 00000000000..bd69b639be3
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/PasswordChangeTab.jsx
@@ -0,0 +1,44 @@
+import React, { useEffect } from 'react';
+import {
+ Divider, Form, Header, Modal, Segment,
+} from 'semantic-ui-react';
+import useInputState from '../../../lib/hooks/useInputState';
+import { destroyOtherSessionsUrl, updateUserUrl } from '../../../lib/requests/routes.js.erb';
+import I18n from '../../../lib/i18n';
+import RailsForm from './RailsForm';
+
+export default function PasswordChangeTab({ user, recentlyAuthenticated }) {
+ const [password, setPassword] = useInputState('');
+ const [confirmPassword, setConfirmPassword] = useInputState('');
+ // Hack to allow this with devise
+ useEffect(() => {
+ if (!recentlyAuthenticated) {
+ document.getElementById('2fa-check').style.display = 'block';
+ }
+ }, [recentlyAuthenticated]);
+
+ if (!recentlyAuthenticated) {
+ return
;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {I18n.t('users.edit.save')}
+
+
+ {I18n.t('users.edit.actions')}
+
+
+ {I18n.t('users.edit.sign_out_of_devices')}
+
+
+
+ );
+}
diff --git a/app/webpacker/components/Persons/Edit/PreferencesTab.jsx b/app/webpacker/components/Persons/Edit/PreferencesTab.jsx
new file mode 100644
index 00000000000..a9010e31c35
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/PreferencesTab.jsx
@@ -0,0 +1,77 @@
+import React, { useCallback, useState } from 'react';
+import {
+ Form, Header, Message, Segment,
+} from 'semantic-ui-react';
+import { useMutation } from '@tanstack/react-query';
+import useCheckboxState from '../../../lib/hooks/useCheckboxState';
+import I18n from '../../../lib/i18n';
+import I18nHTMLTranslate from '../../I18nHTMLTranslate';
+import { EventSelector } from '../../wca/EventSelector';
+import { updatePreferences } from '../api/updatePreferences';
+
+export default function PreferencesTab({ user }) {
+ const [preferredEvents, setPreferredEvents] = useState(user.preferred_events.map((e) => e.id));
+ const [resultsPostedNotification,
+ setResultsPostedNotification] = useCheckboxState(user.results_notifications_enabled);
+ const [registrationNotifications,
+ setRegistrationNotifications] = useCheckboxState(user.registration_notifications_enabled);
+ const {
+ mutate: updatePreferenceMutation,
+ isSuccess,
+ isError,
+ isPending,
+ } = useMutation({ mutationFn: updatePreferences });
+
+ const onSubmit = useCallback((event) => {
+ event.preventDefault();
+ updatePreferenceMutation({
+ userId: user.id,
+ registrationNotificationsEnabled: registrationNotifications,
+ resultsNotificationsEnabled: resultsPostedNotification,
+ preferredEventIds: preferredEvents,
+ });
+ }, [
+ preferredEvents,
+ registrationNotifications,
+ resultsPostedNotification,
+ updatePreferenceMutation,
+ user.id,
+ ]);
+
+ return (
+
+ { isSuccess && {I18n.t('users.successes.messages.account_updated')} }
+ { isError && Something went wrong updating your Preferences }
+ setPreferredEvents(
+ (prev) => (prev.includes(eventId)
+ ? prev.filter((e) => e !== eventId)
+ : [...prev, eventId]),
+ )}
+ />
+ {I18n.t('layouts.navigation.notifications')}
+
+
+ {/* i18n-tasks-use t('simple_form.hints.user.results_notifications_enabled') */}
+
+
+
+
+ {/* i18n-tasks-use t('simple_form.hints.user.registration_notifications_enabled') */}
+
+
+ Save
+
+
+ );
+}
diff --git a/app/webpacker/components/Persons/Edit/RailsForm.jsx b/app/webpacker/components/Persons/Edit/RailsForm.jsx
new file mode 100644
index 00000000000..c3f3141b673
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/RailsForm.jsx
@@ -0,0 +1,16 @@
+import { Form } from 'semantic-ui-react';
+import React from 'react';
+
+export default function RailsForm({ method, action, children }) {
+ return (
+
+ );
+}
diff --git a/app/webpacker/components/Persons/Edit/TwoFactorChangeTab.jsx b/app/webpacker/components/Persons/Edit/TwoFactorChangeTab.jsx
new file mode 100644
index 00000000000..a873b712a30
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/TwoFactorChangeTab.jsx
@@ -0,0 +1,133 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ Button, Message, List, Modal, Header, Form, Segment,
+} from 'semantic-ui-react';
+import { useMutation } from '@tanstack/react-query';
+import I18nHTMLTranslate from '../../I18nHTMLTranslate';
+import I18n from '../../../lib/i18n';
+import { enable2FaUrl, disable2FaUrl } from '../../../lib/requests/routes.js.erb';
+import { getBackupCodes } from '../api/getBackupCodes';
+import RailsForm from './RailsForm';
+
+export default function TwoFactorChangeTab({ user, recentlyAuthenticated, otpSVG }) {
+ const [backupCodes, setBackupCodes] = useState(null);
+
+ // Hack to allow this with devise
+ useEffect(() => {
+ if (!recentlyAuthenticated) {
+ document.getElementById('2fa-check').style.display = 'block';
+ }
+ }, [recentlyAuthenticated]);
+
+ const {
+ mutate: getBackupCodesMutation,
+ } = useMutation({
+ mutationFn: getBackupCodes,
+ onSuccess: (data) => {
+ setBackupCodes(data.codes);
+ },
+ });
+
+ const handleGenerateBackupCodes = useCallback(() => {
+ getBackupCodesMutation();
+ }, [getBackupCodesMutation]);
+
+ if (!recentlyAuthenticated) {
+ return
;
+ }
+
+ return (
+
+ ${I18n.t(
+ 'devise.sessions.new.2fa.name',
+ )}`,
+ }}
+ />
+
+ {I18n.t('devise.sessions.new.2fa.options.app')}
+ {I18n.t('devise.sessions.new.2fa.options.recovery')}
+ {I18n.t('devise.sessions.new.2fa.options.email')}
+
+
+ ${I18n.t(
+ user.otp_required_for_login
+ ? 'devise.sessions.new.2fa.enabled'
+ : 'devise.sessions.new.2fa.disabled',
+ )}`,
+ }}
+ />
+
+ {!user.otp_required_for_login ? (
+
+
+ {I18n.t('devise.sessions.new.2fa.enable')}
+
+
+ ) : (
+ <>
+
+
+
+ {I18n.t('devise.sessions.new.2fa.reset')}
+
+
+
+
+ {I18n.t('devise.sessions.new.2fa.methods')}
+
+
+ {I18n.t('devise.sessions.new.2fa.dedicated_app')}
+ Authy',
+ google: 'Google Authenticator ',
+ microsoft: 'Microsoft Authenticator ',
+ }}
+ />
+ ${I18n.t('common.here')}`,
+ }}
+ />
+
+
+ {I18n.t('devise.sessions.new.2fa.backup_codes')}
+
+
+ {I18n.t('devise.sessions.new.2fa.backup_codes_warning')}
+
+ {!backupCodes ? (
+ <>
+
+
+ {I18n.t('devise.sessions.new.2fa.generate_backup_codes')}
+
+ >
+ ) : (
+ {backupCodes.join('\n')}
+ )}
+
+ {I18n.t('devise.sessions.new.2fa.email_auth')}
+
+
+ {I18n.t('devise.sessions.new.2fa.disable_section_title')}
+
+
+
+ {I18n.t('devise.sessions.new.2fa.disable')}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/app/webpacker/components/Persons/Edit/index.jsx b/app/webpacker/components/Persons/Edit/index.jsx
new file mode 100644
index 00000000000..9d0dd037569
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/index.jsx
@@ -0,0 +1,209 @@
+import React, { useMemo } from 'react';
+import {
+ Container, Header, Message, Tab,
+} from 'semantic-ui-react';
+import WCAQueryClientProvider from '../../../lib/providers/WCAQueryClientProvider';
+import I18n from '../../../lib/i18n';
+import EditAvatar from '../../EditAvatar';
+import RolesTab from '../../RolesTab';
+import GeneralChangesTab from './GeneralChangesTab';
+import EmailChangeTab from './EmailChangeTab';
+import PasswordChangeTab from './PasswordChangeTab';
+import PreferencesTab from './PreferencesTab';
+import TwoFactorChangeTab from './TwoFactorChangeTab';
+import { personUrl } from '../../../lib/requests/routes.js.erb';
+
+export default function Wrapper({
+ user, currentUser, editableFields, recentlyAuthenticated, otpSVG,
+}) {
+ return (
+
+
+
+ );
+}
+
+function getFormWarnings(user, currentUser) {
+ const warnings = [];
+
+ if (!user.confirmed_at) {
+ warnings.push({ content: I18n.t('users.edit.unconfirmed_email', { email: user.email }) });
+ }
+
+ if (user.unconfirmed_wca_id) {
+ const options = {
+ wca_id: `
${user.unconfirmed_wca_id} `,
+ delegate: `
${user.delegate_to_handle_wca_id_claim.name} `,
+ };
+ if (user.confirmed_at) {
+ warnings.push({ content: I18n.t('users.edit.pending_claim_confirmation_html', options) });
+ } else {
+ warnings.push({ content: I18n.t('users.edit.pending_claim_mail_confirmation_html', options) });
+ }
+ }
+
+ if (user.unconfirmed_email) {
+ warnings.push({ content: I18n.t('users.edit.pending_mail_confirmation', { email: user.unconfirmed_email }) });
+ }
+
+ if (user['dummy_account?']) {
+ warnings.push({
+ content: `This account is a dummy account. It serves as a placeholder because the competitor
+ uploaded a profile picture before the website supported WCA accounts. This
+ account will be automatically deleted when another user is assigned WCA
+ id ${user.wca_id}.
+ See "https://github.com/thewca/worldcubeassociation.org/commit/32624f95b2c9e68967f8680ffa3ed7aefccd5319 for more details.`,
+ });
+ }
+
+ if (user.cannot_register_for_competition_reasons.length > 0) {
+ warnings.push({ content: I18n.t('users.edit.please_fix_profile'), list: user.cannot_register_for_competition_reasons });
+ }
+
+ if (currentUser.cannot_edit_data_reason_html) {
+ warnings.push({ content: currentUser.cannot_edit_data_reason_html });
+ }
+
+ return warnings;
+}
+
+const getSlugFromPath = () => {
+ if (window.location.hash) {
+ return window.location.hash.substring(1);
+ }
+ return null;
+};
+
+const tabIndexFromSlug = (panes) => {
+ const pathSlug = getSlugFromPath();
+ if (!pathSlug) {
+ return 0;
+ }
+ return panes.findIndex((p) => p.slug === pathSlug);
+};
+
+const updatePath = (tabSlug) => {
+ window.history.replaceState({}, '', `${window.location.pathname}#${tabSlug}`);
+};
+
+function EditUser({
+ user, currentUser, editableFields, recentlyAuthenticated, otpSVG,
+}) {
+ const warnings = useMemo(() => getFormWarnings(user, currentUser), [user, currentUser]);
+ const panes = useMemo(() => {
+ const p = [{
+ slug: 'general',
+ menuItem: I18n.t('users.edit.general'),
+ render: () => (
+
+ ),
+ }];
+ if (user.id === currentUser.id) {
+ p.push(
+ {
+ slug: 'email',
+ menuItem: I18n.t('users.edit.email'),
+ render: () => (
+
+ ),
+ },
+ {
+ slug: 'preferences',
+ menuItem: I18n.t('users.edit.preferences'),
+ render: () => (
+
+ ),
+ },
+ {
+ slug: 'password',
+ menuItem: I18n.t('users.edit.password'),
+ render: () => (
+
+ ),
+ },
+ {
+ slug: '2fa',
+ menuItem: '2FA',
+ render: () => (
+
+ ),
+ },
+ );
+ }
+ if (currentUser['can_change_users_avatar?']) {
+ p.push({
+ slug: 'avatar',
+ menuItem: 'Avatar',
+ render: () => (
+
+ ),
+ });
+ }
+ if (currentUser['can_view_all_users?']) {
+ p.push({
+ slug: 'roles',
+ menuItem: 'Roles',
+ render: () => (
+
+ ),
+ });
+ }
+ return p;
+ }, [user, currentUser, editableFields, recentlyAuthenticated, otpSVG]);
+
+ return (
+
+
+ {warnings.map((warning) => (
+
+
+
+ ))}
+ {
+ const tab = panes[activeIndex];
+ updatePath(tab.slug);
+ }}
+ />
+
+ );
+}
diff --git a/app/webpacker/components/Persons/Edit/preferences.scss b/app/webpacker/components/Persons/Edit/preferences.scss
new file mode 100644
index 00000000000..4f38001f4ce
--- /dev/null
+++ b/app/webpacker/components/Persons/Edit/preferences.scss
@@ -0,0 +1,10 @@
+.preferences-form {
+ .disabled {
+ opacity: 0.75 !important;
+ cursor: not-allowed;
+ pointer-events: all;
+ label {
+ opacity: 1 !important;
+ }
+ }
+}
diff --git a/app/webpacker/components/Persons/api/getBackupCodes.js b/app/webpacker/components/Persons/api/getBackupCodes.js
new file mode 100644
index 00000000000..445ed77ae38
--- /dev/null
+++ b/app/webpacker/components/Persons/api/getBackupCodes.js
@@ -0,0 +1,12 @@
+import { fetchJsonOrError } from '../../../lib/requests/fetchWithAuthenticityToken';
+import { generateBackupCodesUrl } from '../../../lib/requests/routes.js.erb';
+
+// eslint-disable-next-line import/prefer-default-export
+export async function getBackupCodes() {
+ const { data } = await fetchJsonOrError(
+ generateBackupCodesUrl(),
+ { method: 'POST', 'Content-Type': 'application/json' },
+ );
+
+ return data;
+}
diff --git a/app/webpacker/components/Persons/api/updatePreferences.js b/app/webpacker/components/Persons/api/updatePreferences.js
new file mode 100644
index 00000000000..64c9c7202be
--- /dev/null
+++ b/app/webpacker/components/Persons/api/updatePreferences.js
@@ -0,0 +1,21 @@
+import { fetchWithAuthenticityToken } from '../../../lib/requests/fetchWithAuthenticityToken';
+import { updatePreferencesUrl } from '../../../lib/requests/routes.js.erb';
+
+// eslint-disable-next-line import/prefer-default-export
+export async function updatePreferences({
+ userId, preferredEventIds, resultsNotificationsEnabled, registrationNotificationsEnabled,
+}) {
+ const { data } = await fetchWithAuthenticityToken(
+ updatePreferencesUrl(userId),
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ preferred_event_ids: preferredEventIds,
+ results_notifications_enabled: resultsNotificationsEnabled,
+ registration_notifications_enabled: registrationNotificationsEnabled,
+ }),
+ },
+ );
+ return data;
+}
diff --git a/app/webpacker/lib/requests/routes.js.erb b/app/webpacker/lib/requests/routes.js.erb
index c696d87fa24..43f2e41feaa 100644
--- a/app/webpacker/lib/requests/routes.js.erb
+++ b/app/webpacker/lib/requests/routes.js.erb
@@ -7,8 +7,22 @@ function jsonToQueryString(json) {
export const editPersonUrl = (userId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.edit_user_path("${userId}"))%>`;
+export const updateUserUrl = (userId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.user_path("${userId}"))%>`;
+
+export const updatePreferencesUrl = (userId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.update_preferences_path("${userId}"))%>`;
+
+export const enable2FaUrl = () => `<%= CGI.unescape(Rails.application.routes.url_helpers.profile_enable_2fa_path)%>`
+
+export const destroyOtherSessionsUrl = () => `<%= CGI.unescape(Rails.application.routes.url_helpers.destroy_other_user_sessions_path)%>`
+
+export const disable2FaUrl = () => `<%= CGI.unescape(Rails.application.routes.url_helpers.profile_disable_2fa_path)%>`
+
+export const generateBackupCodesUrl = () => `<%= CGI.unescape(Rails.application.routes.url_helpers.profile_generate_2fa_backup_path)%>`
+
export const personUrl = (wcaId) => `<%= CGI.unescape(Rails.application.routes.url_helpers.person_path("${wcaId}"))%>`;
+export const profileClaimWCAIdUrl = () => `<%= CGI.unescape(Rails.application.routes.url_helpers.profile_claim_wca_id_path)%>`;
+
export const personsUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.persons_path) %>`;
export const usersUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.users_path) %>`;
@@ -323,4 +337,3 @@ export const mergeProfilesUrl = `<%= CGI.unescape(Rails.application.routes.url_h
export const anonymizePersonUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_anonymize_person_path) %>`;
export const reassignConnectedWcaIdUrl = `<%= CGI.unescape(Rails.application.routes.url_helpers.admin_reassign_wca_id_path) %>`;
-
diff --git a/config/i18n.yml b/config/i18n.yml
index 69ba5a12123..9b43c84d232 100644
--- a/config/i18n.yml
+++ b/config/i18n.yml
@@ -76,3 +76,5 @@ translations:
- "*.users.edit.*"
- "*.persons.index.*"
- "*.validators.*"
+ - "*.devise.sessions.new.*"
+ - "*.users.successes.*"
diff --git a/config/routes.rb b/config/routes.rb
index 75d54e32923..6fb16b0c2fd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -59,6 +59,7 @@
get 'users/:id/avatar' => 'users#avatar_data', as: :users_avatar_data
post 'users/:id/avatar' => 'users#upload_avatar'
patch 'users/:id/avatar' => 'users#update_avatar'
+ patch 'users/:id/preferences' => 'users#update_preferences', as: :update_preferences
delete 'users/:id/avatar' => 'users#delete_avatar'
post 'users/:id/anonymize' => 'users#anonymize', as: :anonymize_user
get 'admin/avatars/pending' => 'admin/avatars#pending_avatar_users', as: :pending_avatars