diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e4628d27653..39c3ead60b8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -180,6 +180,31 @@ def upload_avatar end end + def update_preferences + user = user_to_edit + user.current_user = current_user + return render json: { ok: false } unless current_user&.can_edit_user?(user) + + preferred_events = params[:preferred_event_ids] + if preferred_events.present? + user.update(preferred_events: Event.find(preferred_events)) + end + + results_notifications_enabled = params[:results_notifications_enabled] + + if params.key?(:results_notifications_enabled) + user.update(results_notifications_enabled: results_notifications_enabled) + end + + registration_notifications_enabled = params[:registration_notifications_enabled] + + if params.key?(:registration_notifications_enabled) + user.update(registration_notifications_enabled: registration_notifications_enabled) + end + + render json: { ok: true } + end + def update_avatar avatar_id = params.require(:avatarId) diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index c84c1a44eb5..510f0075c90 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -1,194 +1,46 @@ <% provide(:title, @user.name) %> -
-

<%= yield(:title) %> (<%= mail_to @user.email %>)

- <% 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? %> -
- -
- <%= f.input_field :unconfirmed_wca_id, as: :wca_id, disabled: !editable_fields.include?(:unconfirmed_wca_id), class: "form-control" %> - - - <%= t '.profile' %> - - -
- -
-
-
- <% 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 %> +
+
- -
+<% 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 && ( + + )} + + + + + + + { 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 ( +
+ + + {children} +
+ ); +} 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 ? ( + <> + + + + ) : ( +
{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 ( + +
+ {user.name} + {' '} + ( + {user.email} + ) +
+ {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