refactor(api): emit moderation events and apply account actions (#3031)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 12:24:15 +02:00
1 parent c488906131
commit 364084c819
385 files changed
+8994 -22221

No files matched your search

@@ -170,15 +170,6 @@ function isPhoneView(kind: RequiredActionViewKind): boolean {
);
}
function isPhoneInboundView(kind: RequiredActionViewKind): boolean {
return (
kind === 'phone-inbound-start' ||
kind === 'phone-inbound-prepare' ||
kind === 'phone-inbound-send' ||
kind === 'phone-inbound-wait'
);
}
function getIntroDescription(flow: RequiredActionFlow | null, isEmailBounced: boolean): MessageDescriptor {
if (isEmailBounced) return STEP_INTRO_BOUNCED_EMAIL_DESCRIPTION_DESCRIPTOR;
if (flow?.mode === 'email_and_phone') return STEP_INTRO_EMAIL_AND_PHONE_DESCRIPTION_DESCRIPTOR;
@@ -288,18 +279,6 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
return previous ? current.slice(0, -1) : current;
});
}, []);
const goToPhoneCodeFromInbound = useCallback(() => {
setCarouselDirection(1);
setActionError(null);
setHistory((current) => {
const retainedHistory = current.filter((entry) => !isPhoneInboundView(entry.kind));
if (retainedHistory.at(-1)?.kind === 'phone-number') {
return retainedHistory;
}
return [...retainedHistory, {kind: 'phone-number'}];
});
setView({kind: 'phone-code'});
}, []);
const switchToPhonePath = useCallback(() => {
setSelectedVerificationType('phone');
}, []);
@@ -462,7 +441,6 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
? {
code: phoneVerification.screen.code,
ourNumber: phoneVerification.screen.ourNumber,
reason: phoneVerification.screen.reason,
}
: null;
const renderView = (): React.ReactNode => {
@@ -831,11 +809,9 @@ const RequiredActionModal: React.FC<{mock?: boolean}> = observer(({mock = false}
/>
<Button
onClick={() =>
void runPhoneAction(phoneVerification.onRefreshInboundChallenge, 'general').then((result) => {
if (result === 'inbound-challenge') {
void runPhoneAction(phoneVerification.onRefreshInboundChallenge, 'general').then((refreshed) => {
if (refreshed) {
goBackToFallback({kind: 'phone-inbound-send'});
} else if (result === 'phone-code') {
goToPhoneCodeFromInbound();
}
})
}
@@ -315,9 +315,9 @@ export const ESCAPE_CONFIRM_TITLE_WITH_GUILDS_DESCRIPTOR = msg({
});
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.',
'Your account goes back to normal right away and you will not need a phone number now. This is not a permanent exemption, and we can ask for this check again later.',
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.',
'First line of the set-aside confirmation in the required-action modal. Always shown. Says can rather than will, because the check may or may not come back.',
});
export const ESCAPE_CONFIRM_LEAVE_DESCRIPTOR = msg({
message: 'You will leave {guildNames}. Nothing you posted is deleted, and you can join again later.',
@@ -420,8 +420,8 @@ export const PHONE_NUMBER_DESCRIPTION_DESCRIPTOR = msg({
});
export const PHONE_PRIVACY_DESCRIPTOR = msg({
message:
'Your number is not linked to your account. We keep only an encrypted marker, with no user ID, for at most {limit} verifications in about {duration} days.',
comment: 'Required-action modal privacy note for phone verification. limit and duration are inserted by code.',
'Your number is not linked to your account. We keep only an encrypted marker, with no user ID, for a limited time.',
comment: 'Required-action modal privacy note for phone verification.',
});
export const ENTER_PHONE_CODE_TITLE_DESCRIPTOR = msg({
message: 'Enter the SMS code',
@@ -439,10 +439,6 @@ export const INBOUND_PHONE_START_DESCRIPTION_DESCRIPTOR = msg({
message: 'For this check, you send us a text message instead of receiving one from us.',
comment: 'Required-action modal body for starting an inbound phone verification challenge.',
});
export const INBOUND_PHONE_EXPENSIVE_REASON_DESCRIPTOR = msg({
message: 'Sending an SMS to this number is expensive, so we need you to text us instead.',
comment: 'Required-action modal explanation for an inbound phone verification challenge due to SMS cost.',
});
export const INBOUND_PHONE_DEFAULT_REASON_DESCRIPTOR = msg({
message: 'This number needs text-in verification instead of an SMS from us.',
comment: 'Required-action modal explanation for an inbound phone verification challenge.',
@@ -570,13 +566,12 @@ export const PHONE_NOT_IN_SERVICE_DESCRIPTOR = msg({
export const PHONE_NOT_MOBILE_DESCRIPTOR = msg({
message:
"This isn't a mobile number, so it can't receive our text. Try a mobile number, or contact support if you think that's wrong.",
comment:
'Required-action form error for landline, toll-free, premium, shared-cost, UAN, voicemail and pager lines. Do not name which line type the provider returned.',
comment: 'Required-action form error when the number is not a mobile line. Do not name the line type.',
});
export const PHONE_NEEDS_REVIEW_DESCRIPTOR = msg({
message: "We couldn't verify this number automatically. Contact support and a person will review your account.",
comment:
'Required-action form error for phone attempts blocked by a fraud signal. Deliberately non-specific. Never name the signal, the score or the threshold.',
'Required-action form error when a phone attempt cannot be verified automatically and needs a support review. Keep it non-specific.',
});
export const CAPTCHA_REQUIRED_DESCRIPTOR = msg({
message: 'A browser check is required before phone verification. Try again from the sign-in page or contact support.',
@@ -1,10 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
MFA_CODE_DIGIT_COUNT,
PHONE_VERIFICATION_LIMIT,
PHONE_VERIFICATION_WINDOW_DAYS,
} from '@app/features/app/config/I18nDisplayConstants';
import {MFA_CODE_DIGIT_COUNT} from '@app/features/app/config/I18nDisplayConstants';
import styles from '@app/features/auth/components/modals/RequiredActionModal.module.css';
import {
COUNTRY_DESCRIPTOR,
@@ -14,7 +10,6 @@ import {
INBOUND_PHONE_CODE_LABEL_DESCRIPTOR,
INBOUND_PHONE_DEFAULT_REASON_DESCRIPTOR,
INBOUND_PHONE_DESTINATION_LABEL_DESCRIPTOR,
INBOUND_PHONE_EXPENSIVE_REASON_DESCRIPTOR,
INBOUND_PHONE_PREPARE_DESCRIPTION_DESCRIPTOR,
INBOUND_PHONE_PREPARE_TITLE_DESCRIPTOR,
INBOUND_PHONE_SEND_DESCRIPTION_DESCRIPTOR,
@@ -44,7 +39,6 @@ import {
type CodeFormInputs,
normalizeVerificationCode,
type PhoneFormInputs,
type PhoneInboundChallengeReason,
type PhoneScreen,
type SubmitCallback,
} from '@app/features/auth/components/modals/required_action/RequiredActionTypes';
@@ -65,7 +59,6 @@ import {
getE164PhoneNumber,
getPhoneNumberPlaceholder,
} from '@app/media/data/CountryCodes';
import type {I18n} from '@lingui/core';
import {useLingui} from '@lingui/react/macro';
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
@@ -145,12 +138,6 @@ const renderCountryValue = (option: CountrySelectOption | null) => {
);
};
function getInboundReasonText(i18n: I18n, reason: PhoneInboundChallengeReason | null): string {
return reason === 'expensive_destination'
? i18n._(INBOUND_PHONE_EXPENSIVE_REASON_DESCRIPTOR)
: i18n._(INBOUND_PHONE_DEFAULT_REASON_DESCRIPTOR);
}
interface InboundPhoneStartStepProps {
requiresInboundPhone: boolean;
}
@@ -179,7 +166,7 @@ export const InboundPhoneInstructionStep: React.FC<InboundPhoneInstructionStepPr
<StepShell
title={i18n._(INBOUND_PHONE_PREPARE_TITLE_DESCRIPTOR)}
description={i18n._(INBOUND_PHONE_PREPARE_DESCRIPTION_DESCRIPTOR)}
notice={getInboundReasonText(i18n, challenge.reason)}
notice={i18n._(INBOUND_PHONE_DEFAULT_REASON_DESCRIPTOR)}
data-flx="auth.required-action.required-action-phone.inbound-phone-instruction-step.step-shell"
/>
);
@@ -249,10 +236,7 @@ export const PhoneNumberStep: React.FC<PhoneNumberFormProps> = ({
<StepShell
title={i18n._(PHONE_NUMBER_TITLE_DESCRIPTOR)}
description={i18n._(PHONE_NUMBER_DESCRIPTION_DESCRIPTOR)}
notice={i18n._(PHONE_PRIVACY_DESCRIPTOR, {
limit: PHONE_VERIFICATION_LIMIT,
duration: PHONE_VERIFICATION_WINDOW_DAYS,
})}
notice={i18n._(PHONE_PRIVACY_DESCRIPTOR)}
data-flx="auth.required-action.required-action-phone.phone-number-step.step-shell"
>
<div
@@ -346,7 +330,7 @@ export interface PhoneVerificationController {
onCodeFormSubmit: SubmitCallback;
onCountryChange: (country: CountryCode) => void;
onPhoneInput: (event: React.ChangeEvent<HTMLInputElement>) => void;
onRefreshInboundChallenge: () => Promise<'inbound-challenge' | 'phone-code' | null>;
onRefreshInboundChallenge: () => Promise<boolean>;
onBackToPhone: () => void;
}
@@ -397,12 +381,7 @@ export const usePhoneVerification = ({
setIsStartingInbound(true);
try {
const challenge = await UserCommands.startInboundPhoneChallenge();
setScreen({
kind: 'phone-inbound-challenge',
code: challenge.challenge_code,
ourNumber: challenge.our_number,
reason: null,
});
setScreen({kind: 'phone-inbound-challenge', code: challenge.challenge_code, ourNumber: challenge.our_number});
} finally {
setIsStartingInbound(false);
}
@@ -423,7 +402,6 @@ export const usePhoneVerification = ({
kind: 'phone-inbound-challenge',
code: verification.challenge_code,
ourNumber: verification.our_number,
reason: verification.reason,
});
return;
}
@@ -445,42 +423,17 @@ export const usePhoneVerification = ({
},
[getCurrentE164PhoneNumber, returnToPhoneStepWithError, i18n],
);
const onRefreshInboundChallenge = useCallback(async (): Promise<'inbound-challenge' | 'phone-code' | null> => {
if (screen.kind !== 'phone-inbound-challenge') return null;
const currentReason = screen.reason;
const onRefreshInboundChallenge = useCallback(async (): Promise<boolean> => {
if (screen.kind !== 'phone-inbound-challenge') return false;
setIsStartingInbound(true);
try {
if (currentReason === 'expensive_destination') {
const e164Phone = getCurrentE164PhoneNumber();
if (!e164Phone) {
returnToPhoneStepWithError();
return null;
}
const verification = await UserCommands.sendPhoneVerification(e164Phone);
if (verification.channel === 'inbound_challenge') {
setScreen({
kind: 'phone-inbound-challenge',
code: verification.challenge_code,
ourNumber: verification.our_number,
reason: verification.reason,
});
return 'inbound-challenge';
}
setScreen({kind: 'phone-code', recipient: e164Phone});
return 'phone-code';
}
const challenge = await UserCommands.startInboundPhoneChallenge();
setScreen({
kind: 'phone-inbound-challenge',
code: challenge.challenge_code,
ourNumber: challenge.our_number,
reason: null,
});
return 'inbound-challenge';
setScreen({kind: 'phone-inbound-challenge', code: challenge.challenge_code, ourNumber: challenge.our_number});
return true;
} finally {
setIsStartingInbound(false);
}
}, [getCurrentE164PhoneNumber, screen, returnToPhoneStepWithError]);
}, [screen]);
const {handleSubmit: onPhoneFormSubmit, isSubmitting: isPhoneSubmitting} = useRequiredActionFormSubmit(
phoneForm,
onStartInbound,
@@ -22,18 +22,9 @@ export interface VerificationTab {
label: string;
}
export type PhoneInboundChallengeReason =
| 'voip'
| 'canadian'
| 'unknown_line_type'
| 'expensive_destination'
| 'account_forced'
| 'behavioural_risk';
export interface ActiveInboundChallenge {
code: string;
ourNumber: string;
reason: PhoneInboundChallengeReason | null;
}
export type EmailScreen =
@@ -46,7 +37,7 @@ export type PhoneScreen =
| {kind: 'phone-number'}
| {kind: 'phone-code'; recipient: string}
| {kind: 'phone-inbound-start'}
| {kind: 'phone-inbound-challenge'; code: string; ourNumber: string; reason: PhoneInboundChallengeReason | null};
| {kind: 'phone-inbound-challenge'; code: string; ourNumber: string};
export const EMAIL_SCREEN_ORDER: ReadonlyArray<EmailScreen['kind']> = [
'email-instructions',