feat(auth): let phone-gated accounts set the check aside (#2478)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 01:11:24 +02:00
1 parent e12b60078a
commit 9b3773c1e6
127 files changed
+6233 -1410

No files matched your search

@@ -124,6 +124,34 @@
line-height: 1.125rem;
}
.altRoutesTitle {
margin: 0 0 0.375rem;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.altRoutesBody {
margin: 0 0 0.5rem;
}
.altRoutesDivider {
height: 0.0625rem;
margin: 0.75rem 0;
background-color: var(--background-header-secondary);
}
.altRoutesAction {
margin-top: 0.5rem;
}
.escapeConfirmLines {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.signOutRow {
display: flex;
align-items: center;
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {PRODUCT_NAME, SUPPORT_EMAIL} from '@app/features/app/config/I18nDisplayConstants';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {
type RequiredActionFlow,
@@ -14,6 +15,11 @@ import {
CHOOSE_METHOD_DESCRIPTION_DESCRIPTOR,
CHOOSE_METHOD_TITLE_DESCRIPTOR,
CONTINUE_DESCRIPTOR,
ESCAPE_FAILED_NOTHING_CHANGED_DESCRIPTOR,
ESCAPE_FAILED_PARTIAL_DESCRIPTOR,
ESCAPE_SUCCESS_EMAIL_REMAINS_TOAST_DESCRIPTOR,
ESCAPE_SUCCESS_TOAST_DESCRIPTOR,
ESCAPE_UNAVAILABLE_DESCRIPTOR,
GET_NEW_CODE_DESCRIPTOR,
NEXT_DESCRIPTOR,
RESEND_EMAIL_DESCRIPTOR,
@@ -37,6 +43,7 @@ import {
NewEmailStep,
useEmailVerification,
} from '@app/features/auth/components/modals/required_action/RequiredActionEmail';
import {buildPhoneGateEscapeConfirmCopy} from '@app/features/auth/components/modals/required_action/RequiredActionEscapeCopy';
import {
InboundPhoneInstructionStep,
InboundPhoneStartStep,
@@ -46,6 +53,7 @@ import {
} from '@app/features/auth/components/modals/required_action/RequiredActionPhone';
import {
BackButton,
RequiredActionAltRoutes,
RequiredActionBackdrop,
resolveRequiredActionErrorMessage,
SignOutFooterRow,
@@ -57,11 +65,16 @@ import type {
PhoneScreen,
} from '@app/features/auth/components/modals/required_action/RequiredActionTypes';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {CANCEL_DESCRIPTOR, VERIFY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
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 {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 {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {PhoneGateEscapePreviewResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import type {MessageDescriptor} from '@lingui/core';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
@@ -188,6 +201,8 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
const [history, setHistory] = useState<Array<RequiredActionView>>([]);
const [carouselDirection, setCarouselDirection] = useState(1);
const [actionError, setActionError] = useState<string | null>(null);
const [escapePreview, setEscapePreview] = useState<PhoneGateEscapePreviewResponse | null>(null);
const [escapeSubmitting, setEscapeSubmitting] = useState(false);
const viewRef = useRef(view);
const isEmailBounced = !mock && !!user?.emailBounced;
const mockFlow = mock ? buildMockRequiredActionFlow() : null;
@@ -219,6 +234,27 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
const requiresInboundPhone = effectiveFlow?.phone?.requiresInboundPhone ?? false;
const canSelfServeEmailRecovery = !mock && !isEmailBounced && user?.verified !== true;
const canUsePhoneRecovery = hasPhoneVerification;
useEffect(() => {
if (mock) {
setEscapePreview({available: true, guilds: [], owned_guilds: []});
return;
}
if (!hasPhoneVerification) {
setEscapePreview(null);
return;
}
let cancelled = false;
void UserCommands.getPhoneGateEscapePreview()
.then((preview) => {
if (!cancelled) setEscapePreview(preview);
})
.catch(() => {
if (!cancelled) setEscapePreview(null);
});
return () => {
cancelled = true;
};
}, [mock, hasPhoneVerification, flowKey]);
const goTo = useCallback((nextView: RequiredActionView) => {
setCarouselDirection(1);
setActionError(null);
@@ -348,6 +384,71 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
},
[i18n],
);
const handleEscape = useCallback(async () => {
const hadGuildsToLeave = (escapePreview?.guilds.length ?? 0) > 0;
setActionError(null);
setEscapeSubmitting(true);
try {
const updated = await UserCommands.executePhoneGateEscape();
Users.handleUserUpdate(updated, {clearMissingOptionalFields: true});
const remaining = updated.required_actions;
const phoneRemains = remaining.some((action) => action.includes('PHONE'));
if (phoneRemains) {
setActionError(i18n._(ESCAPE_UNAVAILABLE_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL}));
return;
}
ToastCommands.success(
remaining.length === 0
? i18n._(ESCAPE_SUCCESS_TOAST_DESCRIPTOR)
: i18n._(ESCAPE_SUCCESS_EMAIL_REMAINS_TOAST_DESCRIPTOR),
);
} catch (error) {
if (failureCode(error) === APIErrorCodes.PHONE_GATE_ESCAPE_UNAVAILABLE) {
setActionError(i18n._(ESCAPE_UNAVAILABLE_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL}));
} else if (hadGuildsToLeave) {
setActionError(i18n._(ESCAPE_FAILED_PARTIAL_DESCRIPTOR));
} else {
setActionError(i18n._(ESCAPE_FAILED_NOTHING_CHANGED_DESCRIPTOR));
}
void UserCommands.getPhoneGateEscapePreview()
.then(setEscapePreview)
.catch(() => undefined);
} finally {
setEscapeSubmitting(false);
}
}, [escapePreview, i18n]);
const handleEscapePress = useCallback(() => {
if (!escapePreview?.available) return;
const copy = buildPhoneGateEscapeConfirmCopy(i18n, {
guildNames: escapePreview.guilds.map((guild) => guild.name),
ownedGuildNames: escapePreview.owned_guilds.map((guild) => guild.name),
emailStepRemains: hasEmailVerification,
});
ModalCommands.push(
ModalCommands.modal(() => (
<ConfirmModal
title={copy.title}
description={
<div className={styles.escapeConfirmLines} data-flx="auth.required-action-modal.escape-confirm.lines">
{copy.bodyLines.map((line) => (
<p key={line} data-flx="auth.required-action-modal.escape-confirm.line">
{line}
</p>
))}
</div>
}
primaryText={copy.primaryText}
primaryVariant={copy.primaryVariant}
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
onPrimary={handleEscape}
data-flx="auth.required-action-modal.escape-confirm.confirm-modal"
/>
)),
);
}, [escapePreview, hasEmailVerification, handleEscape, i18n]);
const escapeAction = escapePreview?.available
? {guildCount: escapePreview.guilds.length, submitting: escapeSubmitting, onPress: handleEscapePress}
: null;
const activeInboundChallenge: ActiveInboundChallenge | null =
phoneVerification.screen.kind === 'phone-inbound-challenge'
? {
@@ -368,6 +469,14 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
<StepShell
title={i18n._(STEP_INTRO_TITLE_DESCRIPTOR)}
description={description}
notice={
hasPhoneVerification ? (
<RequiredActionAltRoutes
escapeAction={escapeAction}
data-flx="auth.required-action-modal.render-view.required-action-alt-routes"
/>
) : undefined
}
data-flx="auth.required-action-modal.render-view.step-shell"
/>
);
@@ -804,6 +913,14 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
{actionError}
</div>
) : null}
{hasPhoneVerification && view.kind !== 'intro' ? (
<div className={styles.supportBlock} data-flx="auth.required-action-modal.alt-routes-outside">
<RequiredActionAltRoutes
escapeAction={escapeAction}
data-flx="auth.required-action-modal.required-action-alt-routes"
/>
</div>
) : null}
<SignOutFooterRow
mock={mock}
isLoggingOut={isLoggingOut}
@@ -265,6 +265,105 @@ export const SUPPORT_LINK_LABEL_DESCRIPTOR = msg({
message: 'Contact support',
comment: 'Link label in the required-action modal. Opens an email to Fluxer support.',
});
export const ALT_ROUTES_TITLE_DESCRIPTOR = msg({
message: "If you can't verify by SMS",
comment:
'Heading of the alternative-routes block in the required-action modal. Introduces human review and the option to set the phone check aside. Must read as a real supported route, not fine print.',
});
export const ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR = msg({
message:
'Email us and a person will review your account. Tell us you cannot verify by SMS and we will take it from there. Replies come by email, so this is not instant.',
comment:
'Body of the human-review option in the required-action modal, rendered directly above the Contact support link. Sets the expectation that a reply takes time.',
});
export const ESCAPE_BUTTON_DESCRIPTOR = msg({
message: 'Set this check aside',
comment:
'Secondary button in the required-action modal that restores the deferred phone check instead of verifying a number. Must not read as skipping or circumventing, and must not use back or undo wording, which collides with the modal back button.',
});
export const ESCAPE_HINT_NO_GUILDS_DESCRIPTOR = msg({
message:
'Your account works normally again straight away and nothing is removed. We can ask for this check again later.',
comment:
'Hint under the set-aside button in the required-action modal when the user is in no community that triggers the check. This is the common case. Do not phrase it as undoing a join.',
});
export const ESCAPE_HINT_WITH_GUILDS_DESCRIPTOR = msg({
message:
'You will leave {count, plural, one {# community} other {# communities}} that this check applies to, and your account works normally again.',
comment:
'Hint under the set-aside button in the required-action modal when the user is in communities that trigger the check. count is inserted by code.',
});
export const ESCAPE_CONFIRM_TITLE_NO_GUILDS_DESCRIPTOR = msg({
message: 'Set this check aside?',
comment: 'Confirmation dialog title in the required-action modal when nothing will be left.',
});
export const ESCAPE_CONFIRM_TITLE_WITH_GUILDS_DESCRIPTOR = msg({
message: 'Leave {count, plural, one {# community} other {# communities}} and set this check aside?',
comment:
'Confirmation dialog title in the required-action modal when communities will be left. count is inserted by code.',
});
export const ESCAPE_CONFIRM_OUTCOME_DESCRIPTOR = msg({
message:
'Your account goes back to normal right away and you will not need a phone number now. This is not a permanent exemption. Joining a large or public community can bring this check back.',
comment:
'First line of the set-aside confirmation in the required-action modal. Always shown. Says can rather than will, because whether the check returns depends on account age and instance settings the user cannot see.',
});
export const ESCAPE_CONFIRM_LEAVE_DESCRIPTOR = msg({
message: 'You will leave {guildNames}. Nothing you posted is deleted, and you can join again later.',
comment:
'Confirmation line in the required-action modal, shown only when the user is in communities that trigger the check. guildNames is a comma-separated list inserted by code. Both reassurances answer fears people actually have.',
});
export const ESCAPE_CONFIRM_OWNED_DESCRIPTOR = msg({
message: 'You stay in {ownedNames}. Owners cannot leave their own community.',
comment:
'Confirmation line in the required-action modal explaining that communities the user owns are kept rather than left. ownedNames is a comma-separated list inserted by code.',
});
export const ESCAPE_CONFIRM_EMAIL_REMAINS_DESCRIPTOR = msg({
message: 'You will still need to finish the email step.',
comment:
'Confirmation line in the required-action modal when an email requirement remains alongside the phone check, so the modal stays open afterwards.',
});
export const ESCAPE_CONFIRM_SUPPORT_DESCRIPTOR = msg({
message: 'You can still email {supportEmail} for a human review at any time.',
comment:
'Last line of the set-aside confirmation in the required-action modal. supportEmail is inserted by code. The self-serve route never closes the support route.',
});
export const ESCAPE_CONFIRM_PRIMARY_NO_GUILDS_DESCRIPTOR = msg({
message: 'Set this check aside',
comment:
'Primary button of the set-aside confirmation in the required-action modal when nothing will be left. Uses the normal button style, not the destructive one.',
});
export const ESCAPE_CONFIRM_PRIMARY_WITH_GUILDS_DESCRIPTOR = msg({
message: 'Leave and set aside',
comment:
'Primary button of the set-aside confirmation in the required-action modal when communities will be left. Uses the destructive button style.',
});
export const ESCAPE_SUCCESS_TOAST_DESCRIPTOR = msg({
message: 'Your account is back to normal.',
comment:
'Success toast after the phone check is set aside and no verification steps remain. Deliberately count-free, because any communities that were left already disappear from the sidebar.',
});
export const ESCAPE_SUCCESS_EMAIL_REMAINS_TOAST_DESCRIPTOR = msg({
message: 'The phone check is set aside. One email step is left.',
comment:
'Success toast after the phone check is set aside while an email requirement remains, so the modal stays open on the email step. Never tell someone they are done while a modal is still in front of them.',
});
export const ESCAPE_UNAVAILABLE_DESCRIPTOR = msg({
message: 'This is not available on your account right now. Email {supportEmail} and a person will review it.',
comment:
'Shown in the required-action modal when setting the check aside is refused, and when it reported success but the phone requirement is still in force. supportEmail is inserted by code, and the address is named in full because the support link may have scrolled out of view.',
});
export const ESCAPE_FAILED_NOTHING_CHANGED_DESCRIPTOR = msg({
message: 'That did not go through. Nothing changed. Try again.',
comment:
'Shown in the required-action modal when setting the check aside failed and the user was in no community that would have been left, so nothing can have been lost.',
});
export const ESCAPE_FAILED_PARTIAL_DESCRIPTOR = msg({
message:
'That did not finish. Some communities may already have been left, and your account is still locked. Press Set this check aside again to finish.',
comment:
'Shown in the required-action modal when setting the check aside failed part way through leaving communities. Retrying is safe and continues from where it stopped. Names the button so the reader knows what to press.',
});
export const ADD_WORKING_EMAIL_TITLE_DESCRIPTOR = msg({
message: 'Add a working email',
comment: 'Required-action modal carousel step title for replacing a bounced email address.',
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {setupI18n} from '@lingui/core';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@lingui/core/macro', () => {
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
});
vi.mock('@app/features/app/config/Config', () => ({
default: {
PUBLIC_BUILD_VERSION: 'test',
PUBLIC_RELEASE_CHANNEL: 'canary',
PUBLIC_BOOTSTRAP_API_ENDPOINT: 'https://example.invalid',
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: 'https://example.invalid',
},
}));
const {buildPhoneGateEscapeConfirmCopy, buildPhoneGateEscapeHint} = await import('./RequiredActionEscapeCopy');
const i18n = setupI18n({locale: 'en', messages: {en: {}}});
function confirmCopy(overrides: Partial<Parameters<typeof buildPhoneGateEscapeConfirmCopy>[1]> = {}) {
return buildPhoneGateEscapeConfirmCopy(i18n, {
guildNames: [],
ownedGuildNames: [],
emailStepRemains: false,
...overrides,
});
}
describe('buildPhoneGateEscapeHint', () => {
it('promises no removal when the user is in no community the check applies to', () => {
const hint = buildPhoneGateEscapeHint(i18n, 0);
expect(hint).toContain('nothing is removed');
expect(hint).not.toContain('leave');
});
it('counts the communities that will be left', () => {
expect(buildPhoneGateEscapeHint(i18n, 1)).toContain('1 community');
expect(buildPhoneGateEscapeHint(i18n, 4)).toContain('4 communities');
});
});
describe('buildPhoneGateEscapeConfirmCopy', () => {
it('never mentions leaving when nothing will be left', () => {
const copy = confirmCopy();
expect(copy.title).toBe('Set this check aside?');
expect(copy.primaryText).toBe('Set this check aside');
expect(copy.primaryVariant).toBe('primary');
expect(copy.bodyLines).toHaveLength(2);
for (const line of copy.bodyLines) {
expect(line).not.toContain('leave');
expect(line).not.toContain('join');
}
});
it('uses the singular form and the destructive style for a single community', () => {
const copy = confirmCopy({guildNames: ['Cat Fans']});
expect(copy.title).toBe('Leave 1 community and set this check aside?');
expect(copy.primaryText).toBe('Leave and set aside');
expect(copy.primaryVariant).toBe('danger');
expect(copy.bodyLines[1]).toContain('You will leave Cat Fans.');
});
it('names every community that will be left', () => {
const copy = confirmCopy({guildNames: ['Cat Fans', 'Dog Fans', 'Bird Fans']});
expect(copy.title).toBe('Leave 3 communities and set this check aside?');
expect(copy.bodyLines[1]).toContain('You will leave Cat Fans, Dog Fans, Bird Fans.');
});
it('explains kept communities without claiming anything is left', () => {
const copy = confirmCopy({ownedGuildNames: ['My Server']});
expect(copy.title).toBe('Set this check aside?');
expect(copy.primaryVariant).toBe('primary');
expect(copy.bodyLines[1]).toBe(
'You stay in My Server. Owners cannot leave their own community.',
);
});
it('states the leave line before the ownership line', () => {
const copy = confirmCopy({guildNames: ['Cat Fans'], ownedGuildNames: ['My Server', 'My Other Server']});
expect(copy.bodyLines[1]).toContain('You will leave Cat Fans.');
expect(copy.bodyLines[2]).toBe(
'You stay in My Server, My Other Server. Owners cannot leave their own community.',
);
});
it('warns about the remaining email step before the support line, which is always last', () => {
const copy = confirmCopy({emailStepRemains: true});
expect(copy.bodyLines.at(-2)).toBe('You will still need to finish the email step.');
expect(copy.bodyLines.at(-1)).toBe('You can still email [email protected] for a human review at any time.');
expect(confirmCopy().bodyLines.at(-1)).toBe(
'You can still email [email protected] for a human review at any time.',
);
});
});
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SUPPORT_EMAIL} from '@app/features/app/config/I18nDisplayConstants';
import {
ESCAPE_CONFIRM_EMAIL_REMAINS_DESCRIPTOR,
ESCAPE_CONFIRM_LEAVE_DESCRIPTOR,
ESCAPE_CONFIRM_OUTCOME_DESCRIPTOR,
ESCAPE_CONFIRM_OWNED_DESCRIPTOR,
ESCAPE_CONFIRM_PRIMARY_NO_GUILDS_DESCRIPTOR,
ESCAPE_CONFIRM_PRIMARY_WITH_GUILDS_DESCRIPTOR,
ESCAPE_CONFIRM_SUPPORT_DESCRIPTOR,
ESCAPE_CONFIRM_TITLE_NO_GUILDS_DESCRIPTOR,
ESCAPE_CONFIRM_TITLE_WITH_GUILDS_DESCRIPTOR,
ESCAPE_HINT_NO_GUILDS_DESCRIPTOR,
ESCAPE_HINT_WITH_GUILDS_DESCRIPTOR,
} from '@app/features/auth/components/modals/required_action/RequiredActionDescriptors';
import type {I18n} from '@lingui/core';
export interface PhoneGateEscapePlan {
guildNames: ReadonlyArray<string>;
ownedGuildNames: ReadonlyArray<string>;
emailStepRemains: boolean;
}
export interface PhoneGateEscapeConfirmCopy {
title: string;
bodyLines: ReadonlyArray<string>;
primaryText: string;
primaryVariant: 'primary' | 'danger';
}
export function buildPhoneGateEscapeHint(i18n: I18n, guildCount: number): string {
return guildCount === 0
? i18n._(ESCAPE_HINT_NO_GUILDS_DESCRIPTOR)
: i18n._(ESCAPE_HINT_WITH_GUILDS_DESCRIPTOR, {count: guildCount});
}
export function buildPhoneGateEscapeConfirmCopy(i18n: I18n, plan: PhoneGateEscapePlan): PhoneGateEscapeConfirmCopy {
const leaving = plan.guildNames.length > 0;
const bodyLines: Array<string> = [i18n._(ESCAPE_CONFIRM_OUTCOME_DESCRIPTOR)];
if (leaving) {
bodyLines.push(i18n._(ESCAPE_CONFIRM_LEAVE_DESCRIPTOR, {guildNames: plan.guildNames.join(', ')}));
}
if (plan.ownedGuildNames.length > 0) {
bodyLines.push(
i18n._(ESCAPE_CONFIRM_OWNED_DESCRIPTOR, {ownedNames: plan.ownedGuildNames.join(', ')}),
);
}
if (plan.emailStepRemains) {
bodyLines.push(i18n._(ESCAPE_CONFIRM_EMAIL_REMAINS_DESCRIPTOR));
}
bodyLines.push(i18n._(ESCAPE_CONFIRM_SUPPORT_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL}));
return {
title: leaving
? i18n._(ESCAPE_CONFIRM_TITLE_WITH_GUILDS_DESCRIPTOR, {count: plan.guildNames.length})
: i18n._(ESCAPE_CONFIRM_TITLE_NO_GUILDS_DESCRIPTOR),
bodyLines,
primaryText: leaving
? i18n._(ESCAPE_CONFIRM_PRIMARY_WITH_GUILDS_DESCRIPTOR)
: i18n._(ESCAPE_CONFIRM_PRIMARY_NO_GUILDS_DESCRIPTOR),
primaryVariant: leaving ? 'danger' : 'primary',
};
}
@@ -7,6 +7,8 @@ import {EXAMPLE_EMAIL, SUPPORT_EMAIL, SUPPORT_EMAIL_MAILTO} from '@app/features/
import styles from '@app/features/auth/components/modals/RequiredActionModal.module.css';
import {
A_NEW_VERIFICATION_CODE_HAS_BEEN_SENT_DESCRIPTOR,
ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR,
ALT_ROUTES_TITLE_DESCRIPTOR,
CAPTCHA_REQUIRED_DESCRIPTOR,
CODE_DID_NOT_WORK_DESCRIPTOR,
CODE_EXPIRED_DESCRIPTOR,
@@ -16,6 +18,7 @@ import {
EMAIL_MUST_BE_DIFFERENT_DESCRIPTOR,
ENTER_VALID_EMAIL_DESCRIPTOR,
ENTER_VALID_PHONE_DESCRIPTOR,
ESCAPE_BUTTON_DESCRIPTOR,
FAILED_TO_RESEND_VERIFICATION_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR,
NEW_EMAIL_DESCRIPTOR,
PHONE_ALREADY_USED_DESCRIPTOR,
@@ -27,6 +30,7 @@ import {
TOO_MANY_ATTEMPTS_DESCRIPTOR,
VERIFICATION_SESSION_EXPIRED_DESCRIPTOR,
} from '@app/features/auth/components/modals/required_action/RequiredActionDescriptors';
import {buildPhoneGateEscapeHint} from '@app/features/auth/components/modals/required_action/RequiredActionEscapeCopy';
import type {
CodeFormInputs,
NewEmailFormInputs,
@@ -278,6 +282,53 @@ export const SupportLinkLine: React.FC = () => {
);
};
interface RequiredActionAltRoutesProps {
escapeAction: {
guildCount: number;
submitting: boolean;
onPress: () => void;
} | null;
}
export const RequiredActionAltRoutes: React.FC<RequiredActionAltRoutesProps> = ({escapeAction}) => {
const {i18n} = useLingui();
return (
<div data-flx="auth.required-action-modal.alt-routes">
<p
className={styles.altRoutesTitle}
data-step-focus="true"
tabIndex={-1}
data-flx="auth.required-action-modal.alt-routes.title"
>
{i18n._(ALT_ROUTES_TITLE_DESCRIPTOR)}
</p>
<p className={styles.altRoutesBody} data-flx="auth.required-action-modal.alt-routes.body">
{i18n._(ALT_ROUTES_HUMAN_REVIEW_DESCRIPTOR)}
</p>
<div className={styles.supportBlock} data-flx="auth.required-action-modal.alt-routes.support-block">
<SupportLinkLine data-flx="auth.required-action.required-action-shared.required-action-alt-routes.support-link-line" />
</div>
{escapeAction ? (
<>
<div className={styles.altRoutesDivider} data-flx="auth.required-action-modal.alt-routes.divider" />
<p data-flx="auth.required-action-modal.alt-routes.escape-hint">
{buildPhoneGateEscapeHint(i18n, escapeAction.guildCount)}
</p>
<Button
variant="secondary"
className={styles.altRoutesAction}
submitting={escapeAction.submitting}
onClick={escapeAction.onPress}
data-flx="auth.required-action-modal.alt-routes.button.set-aside"
>
{i18n._(ESCAPE_BUTTON_DESCRIPTOR)}
</Button>
</>
) : null}
</div>
);
};
interface NewEmailAddressFormProps {
form: UseFormReturn<NewEmailFormInputs>;
onSubmit: SubmitCallback;