refactor: simplify account standing and verification levels (#3130)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-02 18:17:41 +02:00
1 parent 1abde06824
commit 1ec8f31253
488 files changed
+10390 -47117

No files matched your search

@@ -0,0 +1,219 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {EXAMPLE_PERSONAL_EMAIL} from '@app/features/app/config/I18nDisplayConstants';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import {VERIFICATION_CODE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {Form} from '@app/features/ui/components/form/Form';
import {Input} from '@app/features/ui/components/form/FormInput';
import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel';
import * as UserCommands from '@app/features/user/commands/UserCommands';
import Users from '@app/features/user/state/Users';
import * as FormUtils from '@app/lib/forms';
import {pushApiErrorModal} from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
const ADD_A_WORKING_EMAIL_DESCRIPTOR = msg({
message: 'Add a working email',
comment: 'Title of the modal that replaces an email address that bounced. Keep it concise.',
});
const NEW_EMAIL_FORM_DESCRIPTOR = msg({
message: 'New email form',
comment: 'Short label in the email change modal. Keep it concise.',
});
const NEW_EMAIL_DESCRIPTOR = msg({
message: 'New email',
comment: 'Short label in the email change modal. Keep it concise.',
});
const INVALID_OR_EXPIRED_CODE_DESCRIPTOR = msg({
message: 'Invalid or expired code',
comment: 'Error message in the email change modal.',
});
const UNABLE_TO_RESEND_CODE_RIGHT_NOW_DESCRIPTOR = msg({
message: 'Unable to resend code right now',
comment: 'Error message in the email change modal.',
});
const EMAIL_CHANGED_DESCRIPTOR = msg({
message: 'Email changed',
comment: 'Short label in the email change modal. Keep it concise.',
});
type Stage = 'newEmail' | 'verifyNew';
const STAGE_ORDER: ReadonlyArray<Stage> = ['newEmail', 'verifyNew'];
const RESEND_COOLDOWN_MS = 30 * 1000;
interface NewEmailForm {
email: string;
}
export const BouncedEmailChangeModal = observer(() => {
const {i18n} = useLingui();
const newEmailForm = useForm<NewEmailForm>({defaultValues: {email: ''}});
const [stage, setStage] = useState<Stage>('newEmail');
const [ticket, setTicket] = useState<string | null>(null);
const [code, setCode] = useState<string>('');
const [codeError, setCodeError] = useState<string | null>(null);
const [resendAt, setResendAt] = useState<Date | null>(null);
const [submitting, setSubmitting] = useState<boolean>(false);
const [now, setNow] = useState<number>(Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const canResend = useMemo(() => !resendAt || resendAt.getTime() <= now, [resendAt, now]);
const secondsRemaining = useMemo(
() => (resendAt ? Math.max(0, Math.ceil((resendAt.getTime() - now) / 1000)) : 0),
[resendAt, now],
);
const handleRequestNew = async (data: NewEmailForm) => {
setSubmitting(true);
try {
const result = await UserCommands.requestBouncedEmailChangeNew(data.email);
setTicket(result.ticket);
setResendAt(result.resend_available_at ? new Date(result.resend_available_at) : null);
setCode('');
setCodeError(null);
setStage('verifyNew');
} finally {
setSubmitting(false);
}
};
const handleResend = async () => {
if (!ticket || !canResend) return;
setSubmitting(true);
try {
await UserCommands.resendBouncedEmailChangeNew(ticket);
setResendAt(new Date(Date.now() + RESEND_COOLDOWN_MS));
} catch (error: unknown) {
pushApiErrorModal(i18n, error, i18n._(UNABLE_TO_RESEND_CODE_RIGHT_NOW_DESCRIPTOR));
} finally {
setSubmitting(false);
}
};
const handleVerify = async () => {
if (!ticket) return;
setSubmitting(true);
setCodeError(null);
try {
const updatedUser = await UserCommands.verifyBouncedEmailChangeNew(ticket, code.split(' ').join(''));
Users.handleUserUpdate(updatedUser, {clearMissingOptionalFields: true});
ToastCommands.createToast({type: 'success', children: i18n._(EMAIL_CHANGED_DESCRIPTOR)});
ModalCommands.pop();
} catch (error: unknown) {
setCodeError(
error && typeof error === 'object' && 'body' in error
? FormUtils.extractErrorMessage(i18n, error)
: i18n._(INVALID_OR_EXPIRED_CODE_DESCRIPTOR),
);
} finally {
setSubmitting(false);
}
};
const {handleSubmit: handleNewEmailSubmit} = useFormSubmit({
form: newEmailForm,
onSubmit: handleRequestNew,
defaultErrorField: 'email',
});
const renderNewEmailStage = () => (
<Form
form={newEmailForm}
onSubmit={handleNewEmailSubmit}
aria-label={i18n._(NEW_EMAIL_FORM_DESCRIPTOR)}
data-flx="user.bounced-email-change-modal.form.new-email-submit"
>
<Modal.Description data-flx="user.bounced-email-change-modal.modal-description">
<Trans>
Emails to your current address are not being delivered. Enter a new email and we'll send a code there.
</Trans>
</Modal.Description>
<Modal.InputGroup data-flx="user.bounced-email-change-modal.modal-input-group">
<Input
data-flx="user.bounced-email-change-modal.input.email"
{...newEmailForm.register('email')}
autoComplete="email"
autoFocus={true}
error={newEmailForm.formState.errors.email?.message}
label={i18n._(NEW_EMAIL_DESCRIPTOR)}
maxLength={256}
minLength={1}
placeholder={EXAMPLE_PERSONAL_EMAIL}
required={true}
type="email"
/>
</Modal.InputGroup>
</Form>
);
const renderVerifyNewStage = () => (
<>
<Modal.Description data-flx="user.bounced-email-change-modal.modal-description--2">
<Trans>Enter the code we emailed to your new address.</Trans>
</Modal.Description>
<Modal.InputGroup data-flx="user.bounced-email-change-modal.modal-input-group--2">
<Input
autoFocus={true}
value={code}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setCode(event.target.value)}
label={i18n._(VERIFICATION_CODE_DESCRIPTOR)}
placeholder="XXXX-XXXX"
required={true}
error={codeError ?? undefined}
data-flx="user.bounced-email-change-modal.input.set-code"
/>
</Modal.InputGroup>
</>
);
return (
<Modal.Root size="small" centered data-flx="user.bounced-email-change-modal.modal-root">
<Modal.Header
title={i18n._(ADD_A_WORKING_EMAIL_DESCRIPTOR)}
data-flx="user.bounced-email-change-modal.modal-header"
/>
<Modal.Content data-flx="user.bounced-email-change-modal.modal-content">
<Modal.ContentLayout data-flx="user.bounced-email-change-modal.modal-content-layout">
<SteppedCarousel step={stage} steps={STAGE_ORDER} data-flx="user.bounced-email-change-modal.stepped-carousel">
{stage === 'newEmail' ? renderNewEmailStage() : renderVerifyNewStage()}
</SteppedCarousel>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer data-flx="user.bounced-email-change-modal.footer">
<Button onClick={ModalCommands.pop} variant="secondary" data-flx="user.bounced-email-change-modal.button.pop">
<Trans>Cancel</Trans>
</Button>
{stage === 'newEmail' ? (
<Button
onClick={handleNewEmailSubmit}
submitting={submitting}
data-flx="user.bounced-email-change-modal.button.submit"
>
<Trans>Send code</Trans>
</Button>
) : (
<>
<Button
onClick={handleResend}
disabled={!canResend || submitting}
data-flx="user.bounced-email-change-modal.button.resend"
>
{canResend ? <Trans>Resend</Trans> : <Trans>Resend ({secondsRemaining}s)</Trans>}
</Button>
<Button
onClick={handleVerify}
submitting={submitting}
data-flx="user.bounced-email-change-modal.button.verify"
>
<Trans>Confirm</Trans>
</Button>
</>
)}
</Modal.Footer>
</Modal.Root>
);
});
@@ -25,6 +25,7 @@ import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsC
import styles from '@app/features/user/components/modals/CustomStatusBottomSheet.module.css';
import {type CustomStatus, normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
import Users from '@app/features/user/state/Users';
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {SmileyIcon, XIcon} from '@phosphor-icons/react';
@@ -159,6 +160,8 @@ export const CustomStatusBottomSheet = observer(({isOpen, onClose}: CustomStatus
});
await UserSettingsCommands.update({customStatus: statusToSave});
onClose();
} catch (error) {
if (!handleAccountLimitedError(error)) throw error;
} finally {
setIsSaving(false);
}
@@ -31,6 +31,7 @@ import {ProfilePreview} from '@app/features/user/components/profile/ProfilePrevi
import {type CustomStatus, normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
import UserSettings from '@app/features/user/state/UserSettings';
import Users from '@app/features/user/state/Users';
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {getDaysBetween} from '@fluxer/date_utils/src/DateComparison';
import {getFormattedShortDate, getFormattedTime} from '@fluxer/date_utils/src/DateFormatting';
@@ -233,6 +234,8 @@ export const CustomStatusModal = observer(() => {
});
await UserSettingsCommands.update({customStatus: statusToSave});
ModalCommands.pop();
} catch (error) {
if (!handleAccountLimitedError(error)) throw error;
} finally {
setIsSaving(false);
}
@@ -1,81 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.flagOption {
display: flex;
align-items: center;
gap: 0.5rem;
}
.flagImage {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
object-fit: contain;
}
.flagImageText {
display: inline-flex;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
}
.dialCodeText {
color: var(--text-tertiary);
}
.footerText {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary-muted);
}
.notice {
padding: 0.75rem;
border: 0.0625rem solid var(--border-color);
border-radius: 0.5rem;
background-color: var(--background-secondary);
}
.stepsContainer {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.stepRow {
display: grid;
grid-template-columns: 1.5rem 1fr;
gap: 0.625rem;
align-items: start;
}
.stepBadge {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 62.4375rem;
background-color: var(--brand-primary);
color: var(--text-on-brand-primary);
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
}
.selectable {
user-select: text;
color: var(--text-primary);
}
.selectLabel {
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
}
@@ -1,462 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {ExternalLink} from '@app/features/app/components/shared/ExternalLink';
import {
MFA_CODE_DIGIT_COUNT,
PRODUCT_NAME,
SUPPORT_EMAIL,
SUPPORT_EMAIL_MAILTO,
} from '@app/features/app/config/I18nDisplayConstants';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils';
import {VERIFICATION_CODE_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {Form} from '@app/features/ui/components/form/Form';
import {Combobox, type ComboboxFilterOption} from '@app/features/ui/components/form/FormCombobox';
import {Input} from '@app/features/ui/components/form/FormInput';
import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel';
import * as UserCommands from '@app/features/user/commands/UserCommands';
import styles from '@app/features/user/components/modals/PhoneAddModal.module.css';
import Users from '@app/features/user/state/Users';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import {
COUNTRY_CODES,
type CountryCode,
formatPhoneNumber,
getCountryName,
getDefaultCountry,
getE164PhoneNumber,
getPhoneNumberPlaceholder,
} from '@app/media/data/CountryCodes';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useEffect, useState} from 'react';
import {useForm} from 'react-hook-form';
const PHONE_NUMBER_IS_REQUIRED_DESCRIPTOR = msg({
message: 'Phone number is required',
comment: 'Label in the phone add modal.',
});
const PLEASE_ENTER_A_VALID_PHONE_NUMBER_DESCRIPTOR = msg({
message: 'Enter a valid phone number',
comment: 'Label in the phone add modal.',
});
const VERIFY_PHONE_NUMBER_FORM_DESCRIPTOR = msg({
message: 'Verify phone number form',
comment: 'Label in the phone add modal.',
});
const VERIFY_PHONE_NUMBER_DESCRIPTOR = msg({
message: 'Verify phone number',
comment: 'Short label in the phone add modal. Keep it concise.',
});
const COUNTRY_DESCRIPTOR = msg({
message: 'Country',
comment: 'Short label in the phone add modal. Keep it concise.',
});
const SEARCH_COUNTRIES_DESCRIPTOR = msg({
message: 'Search countries...',
comment: 'Button or menu action label in the phone add modal. Keep it concise.',
});
const PHONE_NUMBER_DESCRIPTOR = msg({
message: 'Phone number',
comment: 'Short label in the phone add modal. Keep it concise.',
});
interface PhoneFormInputs {
phoneNumber: string;
}
interface CodeFormInputs {
code: string;
}
interface CountrySelectOption {
value: string;
label: string;
country: CountryCode;
}
const getCountryOptions = (locale: string): ReadonlyArray<CountrySelectOption> =>
COUNTRY_CODES.map((country) => ({
value: country.code,
label: `${getCountryName(country.code, locale)} (${country.dialCode})`,
country,
}));
const renderCountryOption = (option: CountrySelectOption) => {
const {country} = option;
const locale = LocaleUtils.getCurrentLocale();
const countryName = getCountryName(country.code, locale);
const flagUrl = EmojiUtils.getEmojiURL(country.flag);
return (
<div className={styles.flagOption} data-flx="user.phone-add-modal.country-option.flag-option">
{flagUrl ? (
<img
src={flagUrl}
alt={countryName}
aria-hidden={true}
className={styles.flagImage}
data-flx="user.phone-add-modal.country-option.flag-image"
/>
) : (
<span
className={styles.flagImageText}
role="img"
aria-label={countryName}
data-flx="user.phone-add-modal.country-option.flag-image-text"
>
{country.flag}
</span>
)}
<span data-flx="user.phone-add-modal.country-option.span">{countryName}</span>
<span className={styles.dialCodeText} data-flx="user.phone-add-modal.country-option.dial-code-text">
({country.dialCode})
</span>
</div>
);
};
const renderCountryValue = (option: CountrySelectOption | null) => {
if (!option) return null;
const {country} = option;
const locale = LocaleUtils.getCurrentLocale();
const countryName = getCountryName(country.code, locale);
const flagUrl = EmojiUtils.getEmojiURL(country.flag);
return (
<div className={styles.flagOption} data-flx="user.phone-add-modal.single-value.flag-option">
{flagUrl ? (
<img
src={flagUrl}
alt={countryName}
className={styles.flagImage}
data-flx="user.phone-add-modal.single-value.flag-image"
/>
) : (
<span
className={styles.flagImageText}
role="img"
aria-label={countryName}
data-flx="user.phone-add-modal.single-value.flag-image-text"
>
{country.flag}
</span>
)}
<span data-flx="user.phone-add-modal.single-value.span">{country.dialCode}</span>
</div>
);
};
type PhoneAddStep = 'phone' | 'code' | 'inbound_waiting';
const PHONE_ADD_STEP_ORDER: ReadonlyArray<PhoneAddStep> = ['phone', 'code', 'inbound_waiting'];
export const PhoneAddModal = observer(() => {
const {i18n} = useLingui();
const locale = LocaleUtils.getCurrentLocale();
const countryOptions = getCountryOptions(locale);
const [step, setStep] = useState<PhoneAddStep>('phone');
const [selectedCountry, setSelectedCountry] = useState<CountryCode>(getDefaultCountry());
const [phoneNumber, setPhoneNumber] = useState('');
const [formattedPhone, setFormattedPhone] = useState('');
const [inboundChallenge, setInboundChallenge] =
useState<UserCommands.PhoneSendVerificationInboundChallengeResponse | null>(null);
const phoneForm = useForm<PhoneFormInputs>();
const codeForm = useForm<CodeFormInputs>();
useEffect(() => {
const formatted = formatPhoneNumber(phoneNumber, selectedCountry);
setFormattedPhone(formatted);
}, [phoneNumber, selectedCountry]);
const handlePhoneInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const digitsOnly = value.replace(/\D/g, '');
setPhoneNumber(digitsOnly);
};
const completePhoneVerification = () => {
ModalCommands.pop();
ToastCommands.createToast({type: 'success', children: <Trans>Phone number verified</Trans>});
};
useEffect(() => {
if (step !== 'inbound_waiting' || Users.currentUser?.hasVerifiedPhone !== true) return;
ModalCommands.pop();
ToastCommands.createToast({type: 'success', children: <Trans>Phone number verified</Trans>});
}, [step, Users.currentUser?.hasVerifiedPhone]);
const onSubmitPhone = async () => {
if (!phoneNumber) {
phoneForm.setError('phoneNumber', {message: i18n._(PHONE_NUMBER_IS_REQUIRED_DESCRIPTOR)});
return;
}
const e164Phone = getE164PhoneNumber(phoneNumber, selectedCountry);
if (!e164Phone) {
phoneForm.setError('phoneNumber', {message: i18n._(PLEASE_ENTER_A_VALID_PHONE_NUMBER_DESCRIPTOR)});
return;
}
const verification = await UserCommands.sendPhoneVerification(e164Phone);
if (verification.channel === 'inbound_challenge') {
setInboundChallenge(verification);
setStep('inbound_waiting');
return;
}
setStep('code');
};
const onSubmitCode = async (data: CodeFormInputs) => {
const e164Phone = getE164PhoneNumber(phoneNumber, selectedCountry);
if (!e164Phone) {
setStep('phone');
phoneForm.setError('phoneNumber', {message: i18n._(PLEASE_ENTER_A_VALID_PHONE_NUMBER_DESCRIPTOR)});
return;
}
await UserCommands.verifyPhone(e164Phone, data.code.split(' ').join(''));
completePhoneVerification();
};
const handleInboundResend = async () => {
const e164Phone = getE164PhoneNumber(phoneNumber, selectedCountry);
if (!e164Phone) {
setStep('phone');
phoneForm.setError('phoneNumber', {message: i18n._(PLEASE_ENTER_A_VALID_PHONE_NUMBER_DESCRIPTOR)});
return;
}
const verification = await UserCommands.sendPhoneVerification(e164Phone);
if (verification.channel === 'inbound_challenge') {
setInboundChallenge(verification);
return;
}
setStep('code');
};
const {handleSubmit: handlePhoneSubmit} = useFormSubmit({
form: phoneForm,
onSubmit: onSubmitPhone,
defaultErrorField: 'phoneNumber',
});
const {handleSubmit: handleCodeSubmit} = useFormSubmit({
form: codeForm,
onSubmit: onSubmitCode,
defaultErrorField: 'code',
});
const renderPhoneStep = () => (
<Form
form={phoneForm}
onSubmit={handlePhoneSubmit}
aria-label={i18n._(VERIFY_PHONE_NUMBER_FORM_DESCRIPTOR)}
data-flx="user.phone-add-modal.form.phone-submit"
>
<Modal.InputGroup data-flx="user.phone-add-modal.form-content">
<Combobox
label={i18n._(COUNTRY_DESCRIPTOR)}
value={selectedCountry.code}
onChange={(value) => {
const country = countryOptions.find((o) => o.value === value)?.country;
if (country) {
setSelectedCountry(country);
setPhoneNumber('');
setFormattedPhone('');
}
}}
options={countryOptions}
renderOption={renderCountryOption}
renderValue={renderCountryValue}
placeholder={i18n._(SEARCH_COUNTRIES_DESCRIPTOR)}
filterOption={(option: ComboboxFilterOption<CountrySelectOption>, inputValue: string) => {
const searchTerm = inputValue.toLowerCase();
const countryName = getCountryName(option.data.country.code, locale);
return (
countryName.toLowerCase().includes(searchTerm) ||
option.data.country.dialCode.includes(searchTerm) ||
option.data.country.code.toLowerCase().includes(searchTerm)
);
}}
data-flx="user.phone-add-modal.select"
/>
<Input
data-flx="user.phone-add-modal.input"
{...phoneForm.register('phoneNumber')}
autoComplete="tel"
autoFocus={true}
value={formattedPhone}
onChange={handlePhoneInput}
error={phoneForm.formState.errors.phoneNumber?.message}
label={i18n._(PHONE_NUMBER_DESCRIPTOR)}
placeholder={getPhoneNumberPlaceholder(selectedCountry)}
required={true}
footer={
<p className={styles.footerText} data-flx="user.phone-add-modal.footer-text">
<Trans>
We'll send an SMS code when available. Your number is not linked to your account. We keep only an
encrypted marker, with no user ID, for a limited time.
</Trans>
</p>
}
/>
</Modal.InputGroup>
</Form>
);
const renderInboundWaitingStep = () => {
if (!inboundChallenge) return null;
return (
<>
<div className={styles.notice} data-flx="user.phone-add-modal.notice">
<p className={styles.footerText} data-flx="user.phone-add-modal.footer-text--2">
{RuntimeConfig.isSelfHosted() ? (
<Trans>
Sending an SMS to this phone number is too expensive for {PRODUCT_NAME}, so we need you to send us an
SMS instead. We know this isn't ideal. You can also ask the administrators of this instance to lift this
requirement from your account.
</Trans>
) : (
<Trans>
Sending an SMS to this phone number is too expensive for {PRODUCT_NAME}, so we need you to send us an
SMS instead. We know this isn't ideal. You can also contact{' '}
<ExternalLink href={SUPPORT_EMAIL_MAILTO} data-flx="user.phone-add-modal.external-link">
{SUPPORT_EMAIL}
</ExternalLink>{' '}
to have us lift this requirement from your account.
</Trans>
)}
</p>
</div>
<div className={styles.stepsContainer} data-flx="user.phone-add-modal.steps-container">
<div className={styles.stepRow} data-flx="user.phone-add-modal.step-row">
<div className={styles.stepBadge} data-flx="user.phone-add-modal.step-badge">
1
</div>
<p className={styles.footerText} data-flx="user.phone-add-modal.footer-text--3">
<Trans>Open your phone's messaging app and create a new text message.</Trans>
</p>
</div>
<div className={styles.stepRow} data-flx="user.phone-add-modal.step-row--2">
<div className={styles.stepBadge} data-flx="user.phone-add-modal.step-badge--2">
2
</div>
<p className={styles.footerText} data-flx="user.phone-add-modal.footer-text--4">
<Trans>
Send the code{' '}
<strong className={styles.selectable} data-flx="user.phone-add-modal.selectable">
{inboundChallenge.challenge_code}
</strong>{' '}
to{' '}
<strong className={styles.selectable} data-flx="user.phone-add-modal.selectable--2">
{inboundChallenge.our_number}
</strong>
</Trans>
</p>
</div>
<div className={styles.stepRow} data-flx="user.phone-add-modal.step-row--3">
<div className={styles.stepBadge} data-flx="user.phone-add-modal.step-badge--3">
3
</div>
<p className={styles.footerText} data-flx="user.phone-add-modal.footer-text--5">
<Trans>Wait a moment. This window will close automatically once we receive your message.</Trans>
</p>
</div>
</div>
</>
);
};
const renderCodeStep = () => (
<Form
form={codeForm}
onSubmit={handleCodeSubmit}
aria-label={i18n._(VERIFY_PHONE_NUMBER_FORM_DESCRIPTOR)}
data-flx="user.phone-add-modal.form.code-submit"
>
<Input
data-flx="user.phone-add-modal.input--2"
{...codeForm.register('code')}
autoComplete="one-time-code"
autoFocus={true}
error={codeForm.formState.errors.code?.message}
label={i18n._(VERIFICATION_CODE_DESCRIPTOR)}
required={true}
footer={
<p className={styles.footerText} data-flx="user.phone-add-modal.footer-text--6">
<Trans>
Enter the {MFA_CODE_DIGIT_COUNT}-digit code sent to{' '}
{getE164PhoneNumber(phoneNumber, selectedCountry) ?? formattedPhone}.
</Trans>
</p>
}
/>
</Form>
);
const renderStepBody = () => {
switch (step) {
case 'phone':
return renderPhoneStep();
case 'inbound_waiting':
return renderInboundWaitingStep();
case 'code':
return renderCodeStep();
}
};
const renderStepFooter = () => {
switch (step) {
case 'phone':
return (
<>
<Button onClick={ModalCommands.pop} variant="secondary" data-flx="user.phone-add-modal.button.pop">
<Trans>Cancel</Trans>
</Button>
<Button
onClick={handlePhoneSubmit}
submitting={phoneForm.formState.isSubmitting}
data-flx="user.phone-add-modal.button.submit"
>
<Trans>Verify phone</Trans>
</Button>
</>
);
case 'inbound_waiting':
return (
<>
<Button
variant="secondary"
onClick={() => setStep('phone')}
data-flx="user.phone-add-modal.button.set-step"
>
<Trans>Back</Trans>
</Button>
<Button
onClick={handleInboundResend}
submitting={phoneForm.formState.isSubmitting}
data-flx="user.phone-add-modal.button.inbound-resend"
>
<Trans>Get new code</Trans>
</Button>
</>
);
case 'code':
return (
<>
<Button
onClick={() => setStep('phone')}
variant="secondary"
data-flx="user.phone-add-modal.button.set-step--2"
>
<Trans>Back</Trans>
</Button>
<Button
onClick={handleCodeSubmit}
submitting={codeForm.formState.isSubmitting}
data-flx="user.phone-add-modal.button.submit--2"
>
{i18n._(VERIFY_DESCRIPTOR)}
</Button>
</>
);
}
};
return (
<Modal.Root size="small" centered data-flx="user.phone-add-modal.modal-root">
<Modal.Header title={i18n._(VERIFY_PHONE_NUMBER_DESCRIPTOR)} data-flx="user.phone-add-modal.modal-header" />
<Modal.Content data-flx="user.phone-add-modal.modal-content">
<Modal.ContentLayout data-flx="user.phone-add-modal.modal-content-layout">
<SteppedCarousel step={step} steps={PHONE_ADD_STEP_ORDER} data-flx="user.phone-add-modal.stepped-carousel">
{renderStepBody()}
</SteppedCarousel>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer data-flx="user.phone-add-modal.modal-footer">{renderStepFooter()}</Modal.Footer>
</Modal.Root>
);
});
@@ -54,6 +54,7 @@ import {TimezoneProfileSettings} from '@app/features/user/components/modals/tabs
import {ProfilePreview} from '@app/features/user/components/profile/ProfilePreview';
import type {Profile} from '@app/features/user/models/Profile';
import Users from '@app/features/user/state/Users';
import {ACCOUNT_LIMITED_NOTICE_DESCRIPTOR} from '@app/features/user/utils/AccountLimitUtils';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {setMeaningfulFormValue} from '@app/lib/forms/MeaningfulFormValue';
import {type RemoteFormResetReason, useRemoteFormReset} from '@app/lib/forms/RemoteFormReset';
@@ -251,7 +252,9 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
const [lastFlashTrigger, setLastFlashTrigger] = useState(0);
const [ariaAnnouncement, setAriaAnnouncement] = useState('');
const isClaimed = user?.isClaimed() ?? false;
const isProfileCustomizationLocked = isClaimed && user?.verified === false;
const isProfileEmailLocked = isClaimed && user?.verified === false;
const isProfileAccountLimited = user?.accountLimited === true;
const isProfileCustomizationLocked = isProfileEmailLocked || isProfileAccountLimited;
const form = useForm<FormInputs>({
defaultValues: {
bio: null,
@@ -684,11 +687,13 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
const selectedGuild = selectedGuildId ? guilds.find((g) => g.id === selectedGuildId) : null;
const isPerGuildProfileCustomizationDisabled = isPerGuildProfile && !hasPerGuildProfiles;
const isPronounsDisabled = isProfileCustomizationLocked;
const profileCustomizationDescription = isProfileCustomizationLocked
const profileCustomizationDescription = isProfileEmailLocked
? isPerGuildProfile
? i18n._(VERIFY_YOUR_EMAIL_BEFORE_EDITING_THIS_COMMUNITY_PROFILE_DESCRIPTOR)
: i18n._(VERIFY_YOUR_EMAIL_BEFORE_EDITING_YOUR_PROFILE_YOU_DESCRIPTOR)
: i18n._(EDIT_YOUR_PROFILE_APPEARANCE_AND_SEE_A_LIVE_DESCRIPTOR);
: isProfileAccountLimited
? i18n._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR)
: i18n._(EDIT_YOUR_PROFILE_APPEARANCE_AND_SEE_A_LIVE_DESCRIPTOR);
const hasAvatar =
!avatarAsset.hasCleared &&
(avatarAsset.hasAsset || (!avatarAsset.isDirty && Boolean(profileRemoteValues?.avatar.hasCustomAsset)));
@@ -738,7 +743,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
description={profileCustomizationDescription}
data-flx="user.my-profile-tab.my-profile-tab-component.settings-section"
>
{isProfileCustomizationLocked && (
{isProfileEmailLocked && (
<EmailVerificationAlert
title={
isPerGuildProfile
@@ -15,6 +15,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
import {showUserErrorModal} from '@app/features/user/utils/UserErrorModalUtils';
import {
openEntranceSoundTrimmerModal,
@@ -154,6 +155,7 @@ function useEntranceSoundImpl(selectedScope: EntranceSoundScope): UseEntranceSou
});
} catch (error) {
logger.error('Failed to set entrance sound selection', error);
if (handleAccountLimitedError(error)) return;
showUserErrorModal(
i18n._(
soundId === null ? COULDN_T_REMOVE_ENTRANCE_SOUND_DESCRIPTOR : COULDN_T_SAVE_ENTRANCE_SOUND_DESCRIPTOR,
@@ -174,7 +176,9 @@ function useEntranceSoundImpl(selectedScope: EntranceSoundScope): UseEntranceSou
ToastCommands.createToast({type: 'success', children: i18n._(ENTRANCE_SOUND_SAVED_DESCRIPTOR)});
} catch (error) {
logger.error('Failed to upload trimmed entrance sound', error);
showUserErrorModal(i18n._(COULDN_T_SAVE_ENTRANCE_SOUND_DESCRIPTOR), i18n._(TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR));
if (!handleAccountLimitedError(error)) {
showUserErrorModal(i18n._(COULDN_T_SAVE_ENTRANCE_SOUND_DESCRIPTOR), i18n._(TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR));
}
throw error;
}
},