mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
refactor: simplify account standing and verification levels (#3130)
This commit is contained in:
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;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user