feat(auth): add username sign-in mode and recovery kits (#3215)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-05 14:10:07 +02:00
1 parent bf3d73a5f7
commit e1eecc3b6c
355 files changed
+52138 -24097

No files matched your search

@@ -0,0 +1,81 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import i18nGlobal from '@app/app/I18n';
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as RecoveryKitCommands from '@app/features/user/commands/RecoveryKitCommands';
import RecoveryKitStatus from '@app/features/user/state/RecoveryKitStatus';
import Users from '@app/features/user/state/Users';
import * as FormUtils from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {observer} from 'mobx-react-lite';
import {useEffect} from 'react';
const logger = new Logger('RecoveryKitReminderGate');
const PROMPT_DELAY_MS = 5000;
const PROMPT_TITLE_DESCRIPTOR = msg({
message: 'Save a recovery kit',
comment: 'Title of the one-time prompt asking a signed-in user without a recovery kit to create one.',
});
const PROMPT_BODY_DESCRIPTOR = msg({
message:
'Your account has no email address. If you forget your password, a recovery kit is the only way back in. It only takes a minute.',
comment: 'Body of the one-time prompt asking a signed-in user without a recovery kit to create one.',
});
const PROMPT_CREATE_DESCRIPTOR = msg({
message: 'Create recovery kit',
comment: 'Primary button in the one-time recovery kit prompt.',
});
const PROMPT_LATER_DESCRIPTOR = msg({
message: 'Not now',
comment: 'Secondary button that closes the one-time recovery kit prompt.',
});
function openRecoveryKitPrompt(): void {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18nGlobal._(PROMPT_TITLE_DESCRIPTOR)}
description={i18nGlobal._(PROMPT_BODY_DESCRIPTOR)}
primaryText={i18nGlobal._(PROMPT_CREATE_DESCRIPTOR)}
secondaryText={i18nGlobal._(PROMPT_LATER_DESCRIPTOR)}
onPrimary={async () => {
try {
await RecoveryKitCommands.createAndShowRecoveryKit();
} catch (error) {
FormUtils.pushApiErrorModal(i18nGlobal, error);
}
}}
data-flx="user.recovery-kit-reminder-gate.confirm-modal"
/>
)),
);
}
export const RecoveryKitReminderGate = observer(() => {
const user = Users.currentUser;
const userId = user?.id ?? null;
const eligible = RuntimeConfig.usesUsernameSignIn && Boolean(user?.isClaimed());
useEffect(() => {
if (!eligible || !userId || RecoveryKitStatus.hasShownPrompt(userId)) return;
const timer = window.setTimeout(async () => {
try {
const status = await RecoveryKitCommands.fetchRecoveryKitStatus();
if (status.has_recovery_kit || Users.currentUserId !== userId || RecoveryKitStatus.hasShownPrompt(userId)) {
return;
}
RecoveryKitStatus.markPromptShown(userId);
openRecoveryKitPrompt();
} catch (error) {
logger.warn('Failed to check recovery kit status', error);
}
}, PROMPT_DELAY_MS);
return () => window.clearTimeout(timer);
}, [eligible, userId]);
return null;
});
@@ -4,6 +4,7 @@ import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {EXAMPLE_FLUXER_TAG, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
import {
@@ -87,6 +88,7 @@ export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps)
{feature_custom_discriminator: LimitResolver.resolve({key: 'feature_custom_discriminator', fallback: 0})},
'feature_custom_discriminator',
);
const usernameOnly = RuntimeConfig.usesUniqueUsernames;
const isVisionary = user.premiumType === UserPremiumTypes.LIFETIME;
const showPremium = shouldShowPremiumFeatures();
const skipAvailabilityCheckRef = useRef(false);
@@ -111,6 +113,12 @@ export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps)
const onSubmit = useCallback(
async (data: FormInputs) => {
const usernameValue = data.username.trim();
if (usernameOnly) {
await UserCommands.update({username: usernameValue});
ModalCommands.pop();
ToastCommands.createToast({type: 'success', children: i18n._(USERNAME_UPDATED_DESCRIPTOR)});
return;
}
const normalizedDiscriminator = data.discriminator;
const currentUsername = user.username.trim();
const currentDiscriminator = user.discriminator;
@@ -175,7 +183,7 @@ export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps)
ModalCommands.pop();
ToastCommands.createToast({type: 'success', children: i18n._(USERNAME_UPDATED_DESCRIPTOR)});
},
[hasCustomDiscriminator],
[hasCustomDiscriminator, usernameOnly],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
@@ -184,7 +192,7 @@ export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps)
});
resubmitHandlerRef.current = handleSubmit;
const selectedDiscriminator = form.watch('discriminator');
const hasNonLifetimePremium = showPremium && user.isPremium() && !isVisionary;
const hasNonLifetimePremium = !usernameOnly && showPremium && user.isPremium() && !isVisionary;
const hasPendingDiscriminatorChange = hasNonLifetimePremium && selectedDiscriminator !== user.discriminator;
const shouldShowPremiumDiscriminatorWarning =
hasNonLifetimePremium && (user.premiumDiscriminator || hasPendingDiscriminatorChange);
@@ -242,101 +250,108 @@ export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps)
data-flx="user.fluxer-tag-change-modal.controller"
/>
</div>
<span className={styles.separator} data-flx="user.fluxer-tag-change-modal.separator">
#
</span>
<div className={styles.discriminatorInput} data-flx="user.fluxer-tag-change-modal.discriminator-input">
{!hasCustomDiscriminator ? (
showPremium ? (
<Tooltip
text={i18n._(GET_TO_CUSTOMIZE_YOUR_TAG_OR_KEEP_IT_DESCRIPTOR, {
premiumProductName: PREMIUM_PRODUCT_NAME,
})}
data-flx="user.fluxer-tag-change-modal.tooltip"
>
<div
className={styles.discriminatorInputDisabled}
data-flx="user.fluxer-tag-change-modal.discriminator-input-disabled"
{!usernameOnly && (
<span className={styles.separator} data-flx="user.fluxer-tag-change-modal.separator">
#
</span>
)}
{!usernameOnly && (
<div
className={styles.discriminatorInput}
data-flx="user.fluxer-tag-change-modal.discriminator-input"
>
{!hasCustomDiscriminator ? (
showPremium ? (
<Tooltip
text={i18n._(GET_TO_CUSTOMIZE_YOUR_TAG_OR_KEEP_IT_DESCRIPTOR, {
premiumProductName: PREMIUM_PRODUCT_NAME,
})}
data-flx="user.fluxer-tag-change-modal.tooltip"
>
<Input
data-flx="user.fluxer-tag-change-modal.input.set-value.text"
{...form.register('discriminator')}
aria-label={i18n._(TAG_DESCRIPTOR)}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={true}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value);
}}
/>
<FocusRing offset={-2} data-flx="user.fluxer-tag-change-modal.focus-ring">
<button
type="button"
onClick={() => {
PremiumModalCommands.open();
<div
className={styles.discriminatorInputDisabled}
data-flx="user.fluxer-tag-change-modal.discriminator-input-disabled"
>
<Input
data-flx="user.fluxer-tag-change-modal.input.set-value.text"
{...form.register('discriminator')}
aria-label={i18n._(TAG_DESCRIPTOR)}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={true}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value);
}}
className={styles.discriminatorOverlay}
aria-label={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
data-flx="user.fluxer-tag-change-modal.discriminator-overlay.open.button"
/>
</FocusRing>
</div>
</Tooltip>
) : (
<Tooltip
text={i18n._(CUSTOM_DISCRIMINATORS_ARE_NOT_AVAILABLE_ON_THIS_INSTANCE_DESCRIPTOR)}
data-flx="user.fluxer-tag-change-modal.tooltip--2"
>
<div
className={styles.discriminatorInputDisabled}
data-flx="user.fluxer-tag-change-modal.discriminator-input-disabled--2"
<FocusRing offset={-2} data-flx="user.fluxer-tag-change-modal.focus-ring">
<button
type="button"
onClick={() => {
PremiumModalCommands.open();
}}
className={styles.discriminatorOverlay}
aria-label={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})}
data-flx="user.fluxer-tag-change-modal.discriminator-overlay.open.button"
/>
</FocusRing>
</div>
</Tooltip>
) : (
<Tooltip
text={i18n._(CUSTOM_DISCRIMINATORS_ARE_NOT_AVAILABLE_ON_THIS_INSTANCE_DESCRIPTOR)}
data-flx="user.fluxer-tag-change-modal.tooltip--2"
>
<Input
data-flx="user.fluxer-tag-change-modal.input.set-value.text--2"
{...form.register('discriminator')}
aria-label={i18n._(TAG_DESCRIPTOR)}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={true}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value);
}}
/>
</div>
</Tooltip>
)
) : (
<Input
data-flx="user.fluxer-tag-change-modal.input.set-value.text--3"
{...form.register('discriminator', {
validate: (value) =>
isVisionaryDiscriminator0000Blocked({
showPremium,
isVisionary,
discriminator: value,
})
? i18n._(THE_0000_TAG_IS_RESERVED_FOR_VISIONARY_SUBSCRIBERS_DESCRIPTOR)
: true,
})}
aria-label={i18n._(TAG_DESCRIPTOR)}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={false}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value, {shouldValidate: true});
}}
/>
)}
</div>
<div
className={styles.discriminatorInputDisabled}
data-flx="user.fluxer-tag-change-modal.discriminator-input-disabled--2"
>
<Input
data-flx="user.fluxer-tag-change-modal.input.set-value.text--2"
{...form.register('discriminator')}
aria-label={i18n._(TAG_DESCRIPTOR)}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={true}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value);
}}
/>
</div>
</Tooltip>
)
) : (
<Input
data-flx="user.fluxer-tag-change-modal.input.set-value.text--3"
{...form.register('discriminator', {
validate: (value) =>
isVisionaryDiscriminator0000Blocked({
showPremium,
isVisionary,
discriminator: value,
})
? i18n._(THE_0000_TAG_IS_RESERVED_FOR_VISIONARY_SUBSCRIBERS_DESCRIPTOR)
: true,
})}
aria-label={i18n._(TAG_DESCRIPTOR)}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={false}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value, {shouldValidate: true});
}}
/>
)}
</div>
)}
</div>
<div className={styles.validationBox} data-flx="user.fluxer-tag-change-modal.validation-box">
<UsernameValidationRules
@@ -378,7 +393,7 @@ export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps)
)}
</WarningAlert>
)}
{!hasCustomDiscriminator && (
{!usernameOnly && !hasCustomDiscriminator && (
<PlutoniumUpsell
className={styles.premiumUpsell}
data-flx="user.fluxer-tag-change-modal.premium-upsell"
@@ -2,6 +2,7 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
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';
@@ -9,7 +10,10 @@ 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 RecoveryKitCommands from '@app/features/user/commands/RecoveryKitCommands';
import * as UserCommands from '@app/features/user/commands/UserCommands';
import RecoveryKitStatus from '@app/features/user/state/RecoveryKitStatus';
import Users from '@app/features/user/state/Users';
import * as FormUtils from '@app/lib/forms';
import {pushApiErrorModal} from '@app/lib/forms';
import type {I18n} from '@lingui/core';
@@ -68,7 +72,8 @@ interface PasswordForm {
export const PasswordChangeModal = observer(() => {
const {i18n} = useLingui();
const passwordForm = useForm<PasswordForm>();
const [stage, setStage] = useState<Stage>('intro');
const usesSudo = RuntimeConfig.usesUsernameSignIn;
const [stage, setStage] = useState<Stage>(usesSudo ? 'changePassword' : 'intro');
const [ticket, setTicket] = useState<string | null>(null);
const [verificationProof, setVerificationProof] = useState<string | null>(null);
const [code, setCode] = useState<string>('');
@@ -128,16 +133,27 @@ export const PasswordChangeModal = observer(() => {
}, [ticket, canResend, i18n]);
const onPasswordSubmit = useCallback(
async (data: PasswordForm) => {
if (!ticket || !verificationProof) return;
const emailProof = ticket && verificationProof ? {ticket, verificationProof} : null;
if (!usesSudo && !emailProof) return;
if (data.new_password !== data.confirm_password) {
passwordForm.setError('confirm_password', {message: i18n._(PASSWORDS_DO_NOT_MATCH_DESCRIPTOR)});
return;
}
await UserCommands.completePasswordChange(ticket, verificationProof, data.new_password);
ModalCommands.pop();
if (emailProof) {
await UserCommands.completePasswordChange(emailProof.ticket, emailProof.verificationProof, data.new_password);
ModalCommands.pop();
} else {
const currentUserId = Users.currentUserId;
const hadRecoveryKit = currentUserId ? RecoveryKitStatus.get(currentUserId)?.hasRecoveryKit : undefined;
await UserCommands.updatePasswordWithSudo(data.new_password);
ModalCommands.popByType(PasswordChangeModal);
if (hadRecoveryKit !== false) {
void RecoveryKitCommands.replaceRecoveryKitAfterPasswordChange(data.new_password);
}
}
ToastCommands.createToast({type: 'success', children: <Trans>Password changed</Trans>});
},
[ticket, verificationProof, passwordForm, i18n],
[usesSudo, ticket, verificationProof, passwordForm, i18n],
);
const {handleSubmit: handlePasswordSubmit, isSubmitting: isPasswordSubmitting} = useFormSubmit({
form: passwordForm,
@@ -136,7 +136,7 @@ export const UserProfileActionsSheet: React.FC<UserProfileActionsSheetProps> = o
((isCurrentUser && hasChangeNicknamePermission && !memberIsTimedOut) ||
(hasManageNicknamesPermission && canManageTarget(user.id)));
const handleCopyFluxerTag = () => {
TextCopyCommands.copy(i18n, `${user.username}#${user.discriminator}`, true);
TextCopyCommands.copy(i18n, user.tag, true);
onClose();
};
const handleCopyUserId = () => {
@@ -74,6 +74,7 @@ import {
toProfileDisplayContext,
} from '@app/features/user/utils/ProfileGuildMembership';
import {createMockProfile} from '@app/features/user/utils/ProfileUtils';
import {shouldShowDiscriminator} from '@app/features/user/utils/UserTagUtils';
import * as CallUtils from '@app/features/voice/utils/CallUtils';
import {hasActiveDirectCallWithUser} from '@app/features/voice/utils/PrivateCallMenuUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
@@ -609,7 +610,7 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
>
{displayName}
</span>
{isDisplayNameUsername && (
{isDisplayNameUsername && shouldShowDiscriminator(user) && (
<span
className={styles.discriminator}
data-flx="user.user-profile-mobile-sheet.user-profile-mobile-sheet-content.discriminator"
@@ -627,7 +628,7 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
className={styles.fullTag}
data-flx="user.user-profile-mobile-sheet.user-profile-mobile-sheet-content.full-tag"
>
{NicknameUtils.formatTagForStreamerMode(`${user.username}#${user.discriminator}`)}
{NicknameUtils.formatTagForStreamerMode(user.tag)}
</span>
)}
<div
@@ -340,7 +340,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
openReportUserModal({i18n, user: displayUser, guildId});
};
const handleCopyFluxerTag = () => {
TextCopyCommands.copy(i18n, `${displayUser.username}#${displayUser.discriminator}`, true);
TextCopyCommands.copy(i18n, displayUser.tag, true);
};
const handleCopyUserId = () => {
TextCopyCommands.copy(i18n, displayUser.id, true);
@@ -10,6 +10,10 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import {EmailChangeModal} from '@app/features/user/components/modals/EmailChangeModal';
import {PasswordChangeModal} from '@app/features/user/components/modals/PasswordChangeModal';
import styles from '@app/features/user/components/modals/tabs/account_security_tab/AccountTab.module.css';
import {
RecoveryKitReminderAlert,
RecoveryKitRow,
} from '@app/features/user/components/modals/tabs/account_security_tab/RecoveryKitSettings';
import type {User} from '@app/features/user/models/User';
import * as DateUtils from '@app/features/user/utils/DateFormatting';
import {msg} from '@lingui/core/macro';
@@ -41,7 +45,8 @@ interface AccountTabProps {
export const AccountTabContent: React.FC<AccountTabProps> = observer(
({user, isClaimed, showMaskedEmail, setShowMaskedEmail}) => {
const {i18n} = useLingui();
const emailRow = isClaimed ? (
const usesUsernameSignIn = RuntimeConfig.usesUsernameSignIn;
const emailRow = usesUsernameSignIn ? null : isClaimed ? (
<>
<div className={styles.row} data-flx="user.account-security-tab.account-tab.account-tab-content.email-row">
<div
@@ -129,7 +134,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
);
const passwordRow = (
<div
className={styles.divider}
className={emailRow ? styles.divider : undefined}
data-flx="user.account-security-tab.account-tab.account-tab-content.password-block"
>
<div className={styles.row} data-flx="user.account-security-tab.account-tab.account-tab-content.password-row">
@@ -208,9 +213,21 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
{!isClaimed && (
<UnclaimedAccountAlert data-flx="user.account-security-tab.account-tab.account-tab-content.unclaimed-account-alert" />
)}
{usesUsernameSignIn && isClaimed && (
<RecoveryKitReminderAlert
user={user}
data-flx="user.account-security-tab.account-tab.account-tab-content.recovery-kit-reminder"
/>
)}
<div className={styles.accountRows} data-flx="user.account-security-tab.account-tab.account-tab-content.rows">
{emailRow}
{passwordRow}
{usesUsernameSignIn && isClaimed && (
<RecoveryKitRow
user={user}
data-flx="user.account-security-tab.account-tab.account-tab-content.recovery-kit-row"
/>
)}
</div>
</>
);
@@ -0,0 +1,150 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
import * as RecoveryKitCommands from '@app/features/user/commands/RecoveryKitCommands';
import styles from '@app/features/user/components/modals/tabs/account_security_tab/AccountTab.module.css';
import type {User} from '@app/features/user/models/User';
import RecoveryKitStatus from '@app/features/user/state/RecoveryKitStatus';
import * as DateUtils from '@app/features/user/utils/DateFormatting';
import * as FormUtils from '@app/lib/forms';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useState} from 'react';
const RECOVERY_KIT_ERROR_DESCRIPTOR = msg({
message: 'Could not create a recovery kit',
comment: 'Error title shown when creating a new account recovery kit fails.',
});
const REPLACE_RECOVERY_KIT_TITLE_DESCRIPTOR = msg({
message: 'Create a new recovery kit?',
comment: 'Title of the confirmation shown before replacing an existing account recovery kit.',
});
const REPLACE_RECOVERY_KIT_DESCRIPTION_DESCRIPTOR = msg({
message: 'Your current kit stops working as soon as the new one is created.',
comment: 'Body of the confirmation shown before replacing an existing account recovery kit.',
});
const REPLACE_RECOVERY_KIT_CONFIRM_DESCRIPTOR = msg({
message: 'Create a new kit',
comment: 'Confirm button that replaces the existing account recovery kit with a new one.',
});
export function confirmNewRecoveryKit(i18n: I18n, onConfirm: () => Promise<void> | void): void {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(REPLACE_RECOVERY_KIT_TITLE_DESCRIPTOR)}
description={i18n._(REPLACE_RECOVERY_KIT_DESCRIPTION_DESCRIPTOR)}
primaryText={i18n._(REPLACE_RECOVERY_KIT_CONFIRM_DESCRIPTOR)}
onPrimary={onConfirm}
data-flx="user.account-security-tab.recovery-kit-settings.replace-confirm-modal"
/>
)),
);
}
function useRecoveryKitStatus(user: User) {
const status = RecoveryKitStatus.get(user.id);
useEffect(() => {
if (status) return;
void RecoveryKitCommands.fetchRecoveryKitStatus().catch(() => {});
}, [status, user.id]);
return status;
}
function useCreateRecoveryKit() {
const {i18n} = useLingui();
const [creating, setCreating] = useState(false);
const create = useCallback(async () => {
setCreating(true);
try {
await RecoveryKitCommands.createAndShowRecoveryKit();
} catch (error) {
FormUtils.pushApiErrorModal(i18n, error, i18n._(RECOVERY_KIT_ERROR_DESCRIPTOR));
} finally {
setCreating(false);
}
}, [i18n]);
return {creating, create};
}
export const RecoveryKitRow = observer(({user}: {user: User}) => {
const {i18n} = useLingui();
const status = useRecoveryKitStatus(user);
const {creating, create} = useCreateRecoveryKit();
const createdAt = status?.createdAt ?? null;
return (
<div className={styles.divider} data-flx="user.account-security-tab.recovery-kit-settings.recovery-kit-block">
<div className={styles.row} data-flx="user.account-security-tab.recovery-kit-settings.recovery-kit-row">
<div className={styles.rowContent} data-flx="user.account-security-tab.recovery-kit-settings.row-content">
<div className={styles.label} data-flx="user.account-security-tab.recovery-kit-settings.label">
<Trans>Recovery kit</Trans>
</div>
{status && !status.hasRecoveryKit ? (
<div className={styles.warningText} data-flx="user.account-security-tab.recovery-kit-settings.missing">
<Trans>You don't have a recovery kit yet</Trans>
</div>
) : (
<div className={styles.description} data-flx="user.account-security-tab.recovery-kit-settings.description">
{createdAt ? (
<Trans>Created {DateUtils.getRelativeDateString(createdAt, i18n)}</Trans>
) : (
<Trans>Lets you reset your password if you forget it.</Trans>
)}
</div>
)}
</div>
<Button
small={true}
submitting={creating}
onClick={status?.hasRecoveryKit === false ? create : () => confirmNewRecoveryKit(i18n, create)}
data-flx="user.account-security-tab.recovery-kit-settings.button.create"
>
{status?.hasRecoveryKit === false ? <Trans>Create recovery kit</Trans> : <Trans>Create a new kit</Trans>}
</Button>
</div>
</div>
);
});
export const RecoveryKitReminderAlert = observer(({user}: {user: User}) => {
const status = useRecoveryKitStatus(user);
const {creating, create} = useCreateRecoveryKit();
if (!status || status.hasRecoveryKit || RecoveryKitStatus.isReminderDismissed(user.id)) {
return null;
}
return (
<WarningAlert
title={<Trans>Save a recovery kit</Trans>}
actions={
<>
<Button
small={true}
submitting={creating}
onClick={create}
data-flx="user.account-security-tab.recovery-kit-settings.reminder.button.create"
>
<Trans>Create recovery kit</Trans>
</Button>
<Button
small={true}
variant="secondary"
onClick={() => RecoveryKitStatus.dismissReminder(user.id)}
data-flx="user.account-security-tab.recovery-kit-settings.reminder.button.dismiss"
>
<Trans>Not now</Trans>
</Button>
</>
}
data-flx="user.account-security-tab.recovery-kit-settings.reminder"
>
<Trans>If you forget your password, a recovery kit is the only way back into your account.</Trans>
</WarningAlert>
);
});
@@ -10,6 +10,7 @@ import {
PREMIUM_PRODUCT_NAME,
STATIC_IMAGE_FORMATS,
} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
import type {Gif} from '@app/features/expressions/commands/GifCommands';
@@ -198,10 +199,17 @@ export const AvatarUploader = observer(
className={styles.spacedParagraph}
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.spaced-paragraph"
>
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
{RuntimeConfig.usesUniqueUsernames ? (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
) : (
<Trans>
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
</Trans>
)}
</p>
</>
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands';
@@ -97,7 +98,7 @@ export const UsernameSection = observer(({isClaimed, isEmailVerified, user}: Use
<Trans>Change username</Trans>
</Button>
)}
{!hasCustomDiscriminator && shouldShowPremiumFeatures() && (
{!RuntimeConfig.usesUniqueUsernames && !hasCustomDiscriminator && shouldShowPremiumFeatures() && (
<Tooltip
text={i18n._(CUSTOMIZE_YOUR_TAG_TO_YOUR_LIKING_WITH_DESCRIPTOR, {
discriminatorLabel,
@@ -1,28 +1,154 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import * as UserCommands from '@app/features/user/commands/UserCommands';
import styles from '@app/features/user/components/modals/tabs/privacy_safety_tab/DataDeletionTab.module.css';
import {
DataRequestModal,
EXPORT_TAB_DESCRIPTION,
} from '@app/features/user/components/modals/tabs/privacy_safety_tab/data_request_modal/DataRequestModal';
import * as DateUtils from '@app/features/user/utils/DateFormatting';
import * as FormUtils from '@app/lib/forms';
import type {HarvestStatusResponse} from '@fluxer/schema/src/domains/user/UserHarvestSchemas';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
import {useCallback, useEffect, useState} from 'react';
const HARVEST_POLL_INTERVAL_MS = 10_000;
const DOWNLOAD_FAILED_DESCRIPTOR = msg({
message: 'Could not download your data export',
comment: 'Error title shown when fetching the download link for a finished data export fails.',
});
function isHarvestInProgress(harvest: HarvestStatusResponse | null): boolean {
return harvest?.status === 'pending' || harvest?.status === 'processing';
}
function isHarvestDownloadable(harvest: HarvestStatusResponse): boolean {
if (harvest.status !== 'completed') return false;
return !harvest.expires_at || new Date(harvest.expires_at).getTime() > Date.now();
}
const DataExportStatus = observer(
({
harvest,
onDownload,
downloading,
}: {
harvest: HarvestStatusResponse;
onDownload: () => void;
downloading: boolean;
}) => {
const {i18n} = useLingui();
const requestedAt = DateUtils.getRelativeDateString(harvest.created_at, i18n);
if (isHarvestInProgress(harvest)) {
const progress = Math.round(harvest.progress_percent);
return (
<Modal.Description className={styles.warningText} data-flx="user.privacy-safety-tab.data-export-tab.status">
<Trans>
Your export from {requestedAt} is being prepared ({progress}%).
</Trans>
</Modal.Description>
);
}
if (harvest.status === 'failed') {
return (
<Modal.Description className={styles.warningText} data-flx="user.privacy-safety-tab.data-export-tab.status">
<Trans>Your export from {requestedAt} failed. You can request a new one.</Trans>
</Modal.Description>
);
}
if (!isHarvestDownloadable(harvest)) {
return (
<Modal.Description className={styles.warningText} data-flx="user.privacy-safety-tab.data-export-tab.status">
<Trans>Your export from {requestedAt} has expired. You can request a new one.</Trans>
</Modal.Description>
);
}
const expiresAt = harvest.expires_at ? DateUtils.getFormattedDateTime(harvest.expires_at) : null;
return (
<>
<Modal.Description className={styles.warningText} data-flx="user.privacy-safety-tab.data-export-tab.status">
{expiresAt ? (
<Trans>
Your export from {requestedAt} is ready. The download expires on {expiresAt}.
</Trans>
) : (
<Trans>Your export from {requestedAt} is ready.</Trans>
)}
</Modal.Description>
<Button
variant="secondary"
submitting={downloading}
onClick={onDownload}
data-flx="user.privacy-safety-tab.data-export-tab.button.download"
>
<Trans>Download export</Trans>
</Button>
</>
);
},
);
export const DataExportTabContent: React.FC = observer(() => {
const {i18n} = useLingui();
const showInAppStatus = RuntimeConfig.usesUsernameSignIn;
const [harvest, setHarvest] = useState<HarvestStatusResponse | null>(null);
const [refreshToken, setRefreshToken] = useState(0);
const [downloading, setDownloading] = useState(false);
const inProgress = isHarvestInProgress(harvest);
useEffect(() => {
if (!showInAppStatus) return;
let cancelled = false;
let timer: number | null = null;
const load = async () => {
try {
const latest = await UserCommands.getLatestHarvest();
if (cancelled) return;
setHarvest(latest);
if (isHarvestInProgress(latest)) {
timer = window.setTimeout(load, HARVEST_POLL_INTERVAL_MS);
}
} catch {}
};
void load();
return () => {
cancelled = true;
if (timer !== null) window.clearTimeout(timer);
};
}, [showInAppStatus, refreshToken]);
const handleExportRequested = useCallback(() => setRefreshToken((value) => value + 1), []);
const handleOpen = useCallback(() => {
ModalCommands.push(
modal(() => (
<DataRequestModal variant="export" data-flx="user.privacy-safety-tab.data-export-tab.data-request-modal" />
<DataRequestModal
variant="export"
onExportRequested={handleExportRequested}
data-flx="user.privacy-safety-tab.data-export-tab.data-request-modal"
/>
)),
);
}, []);
}, [handleExportRequested]);
const handleDownload = useCallback(async () => {
if (!harvest) return;
setDownloading(true);
try {
const {download_url: downloadUrl} = await UserCommands.getHarvestDownloadUrl(harvest.harvest_id);
await openExternalUrl(downloadUrl);
} catch (error) {
FormUtils.pushApiErrorModal(i18n, error, i18n._(DOWNLOAD_FAILED_DESCRIPTOR));
} finally {
setDownloading(false);
}
}, [harvest, i18n]);
return (
<div
className={styles.deleteSection}
@@ -34,8 +160,17 @@ export const DataExportTabContent: React.FC = observer(() => {
>
{i18n._(EXPORT_TAB_DESCRIPTION)}
</Modal.Description>
{showInAppStatus && harvest && (
<DataExportStatus
harvest={harvest}
onDownload={handleDownload}
downloading={downloading}
data-flx="user.privacy-safety-tab.data-export-tab.data-export-tab-content.status"
/>
)}
<Button
variant="primary"
disabled={showInAppStatus && inProgress}
onClick={handleOpen}
data-flx="user.privacy-safety-tab.data-export-tab.data-export-tab-content.button.open-modal"
>
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as Modal from '@app/features/app/components/dialogs/Modal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
import Guilds from '@app/features/guild/state/Guilds';
import {
@@ -48,6 +49,10 @@ const EXPORT_SUCCESS_DESCRIPTOR = msg({
message: "We'll process this as soon as possible. You'll get an email when your archive is ready.",
comment: 'Success toast shown after a filtered data export job is queued.',
});
const EXPORT_SUCCESS_IN_APP_DESCRIPTOR = msg({
message: "We'll process this as soon as possible. Check back here to download your archive when it's ready.",
comment: 'Success toast shown after a data export job is queued on an instance where people sign in with a username.',
});
const DELETE_SUCCESS_DESCRIPTOR = msg({
message: "We'll process this as soon as possible. You'll get a DM from us when it's done.",
comment: 'Success toast shown after a bulk message deletion job is queued.',
@@ -119,6 +124,18 @@ const EXPORT_CONFIRM_CUSTOM_DESCRIPTOR = msg({
"We'll build a downloadable archive that matches the filters below and email you when it's ready. The download link in that email expires after 7 days.",
comment: 'Confirm-step copy in the data-export modal when scope is custom.',
});
const EXPORT_CONFIRM_EVERYTHING_IN_APP_DESCRIPTOR = msg({
message:
"We'll build a downloadable archive of every message you have ever sent. You can download it from this page when it's ready. The download expires after 7 days.",
comment:
'Confirm-step copy in the data-export modal when scope is everything, on an instance where people sign in with a username.',
});
const EXPORT_CONFIRM_CUSTOM_IN_APP_DESCRIPTOR = msg({
message:
"We'll build a downloadable archive that matches the filters below. You can download it from this page when it's ready. The download expires after 7 days.",
comment:
'Confirm-step copy in the data-export modal when scope is custom, on an instance where people sign in with a username.',
});
const DELETE_CONFIRM_DESCRIPTOR = msg({
message: 'Permanently delete the messages that match the filters below. This cannot be undone.',
comment: 'Confirm-step copy in the data-deletion modal explaining the destructive operation.',
@@ -295,7 +312,7 @@ interface VariantConfig {
isDelete: boolean;
}
function getVariantConfig(variant: DataRequestVariant): VariantConfig {
function getVariantConfig(variant: DataRequestVariant, usesUsernameSignIn: boolean): VariantConfig {
if (variant === 'export') {
return {
titleDescriptor: EXPORT_TITLE_DESCRIPTOR,
@@ -315,9 +332,13 @@ function getVariantConfig(variant: DataRequestVariant): VariantConfig {
],
submitLabelDescriptor: EXPORT_SUBMIT_DESCRIPTOR,
submitVariant: 'primary',
successToastDescriptor: EXPORT_SUCCESS_DESCRIPTOR,
confirmSimpleDescriptor: EXPORT_CONFIRM_EVERYTHING_DESCRIPTOR,
confirmCustomDescriptor: EXPORT_CONFIRM_CUSTOM_DESCRIPTOR,
successToastDescriptor: usesUsernameSignIn ? EXPORT_SUCCESS_IN_APP_DESCRIPTOR : EXPORT_SUCCESS_DESCRIPTOR,
confirmSimpleDescriptor: usesUsernameSignIn
? EXPORT_CONFIRM_EVERYTHING_IN_APP_DESCRIPTOR
: EXPORT_CONFIRM_EVERYTHING_DESCRIPTOR,
confirmCustomDescriptor: usesUsernameSignIn
? EXPORT_CONFIRM_CUSTOM_IN_APP_DESCRIPTOR
: EXPORT_CONFIRM_CUSTOM_DESCRIPTOR,
isDelete: false,
};
}
@@ -352,11 +373,13 @@ function formatDateForSummary(date: Date, locale: string): string {
interface DataRequestModalProps {
variant: DataRequestVariant;
onExportRequested?: () => void;
}
export const DataRequestModal: React.FC<DataRequestModalProps> = observer(({variant}) => {
export const DataRequestModal: React.FC<DataRequestModalProps> = observer(({variant, onExportRequested}) => {
const {i18n} = useLingui();
const config = useMemo(() => getVariantConfig(variant), [variant]);
const usesUsernameSignIn = RuntimeConfig.usesUsernameSignIn;
const config = useMemo(() => getVariantConfig(variant, usesUsernameSignIn), [variant, usesUsernameSignIn]);
const scopeOptions = useMemo<ReadonlyArray<RadioOption<ScopeValue>>>(
() =>
config.scopeOptionDescriptors.map((opt) => ({
@@ -490,6 +513,7 @@ export const DataRequestModal: React.FC<DataRequestModalProps> = observer(({vari
} else {
await UserCommands.requestFilteredDataHarvest(buildFilter('selected'));
}
onExportRequested?.();
} else {
const filterScope: BulkDeleteMyMessagesFilter['scope'] =
scope === 'inaccessible_only' ? 'inaccessible_only' : 'selected';
@@ -502,7 +526,7 @@ export const DataRequestModal: React.FC<DataRequestModalProps> = observer(({vari
} finally {
setIsSubmitting(false);
}
}, [variant, scope, buildFilter, isSubmitting, i18n, config.successToastDescriptor, closeModal]);
}, [variant, scope, buildFilter, isSubmitting, i18n, config.successToastDescriptor, closeModal, onExportRequested]);
const dateModeOptions = useMemo(
() => [
{value: 'all_time' as const, label: i18n._(DATE_MODE_ALL_TIME_DESCRIPTOR)},
@@ -7,7 +7,11 @@ import {
PASSWORD_DESCRIPTOR,
TWO_FACTOR_AUTHENTICATION_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {shouldShowClaimedAccountSettings} from '@app/features/user/components/settings_utils/search_index/SearchIndexHelpers';
import {
shouldShowClaimedAccountSettings,
shouldShowEmailAccountSettings,
shouldShowRecoveryKitSettings,
} from '@app/features/user/components/settings_utils/search_index/SearchIndexHelpers';
import type {SearchableSettingDescriptor} from '@app/features/user/components/settings_utils/search_index/SearchIndexTypes';
import {
CODES_DESCRIPTOR,
@@ -31,6 +35,22 @@ const CHANGE_YOUR_EMAIL_ADDRESS_DESCRIPTOR = msg({
message: 'Change your email address',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
const RECOVERY_KIT_DESCRIPTOR = msg({
message: 'Recovery kit',
comment: 'Settings search entry label for the account recovery kit used to reset a forgotten password.',
});
const LOST_PASSWORD_DESCRIPTOR = msg({
message: 'Lost password',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const PRINT_DESCRIPTOR = msg({
message: 'Print',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const CREATE_A_RECOVERY_KIT_DESCRIPTOR = msg({
message: 'Create a recovery kit to reset a forgotten password',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
const CREDENTIALS_DESCRIPTOR = msg({
message: 'Credentials',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
@@ -195,7 +215,7 @@ export const accountSecurityIndex: Array<SearchableSettingDescriptor> = [
label: EMAIL_DESCRIPTOR,
keywords: [EMAIL_DESCRIPTOR, MAIL_DESCRIPTOR, ADDRESS_DESCRIPTOR, CONTACT_DESCRIPTOR],
description: CHANGE_YOUR_EMAIL_ADDRESS_DESCRIPTOR,
isVisible: shouldShowClaimedAccountSettings,
isVisible: shouldShowEmailAccountSettings,
},
{
id: 'account-password',
@@ -212,6 +232,21 @@ export const accountSecurityIndex: Array<SearchableSettingDescriptor> = [
description: CHANGE_YOUR_PASSWORD_DESCRIPTOR,
isVisible: shouldShowClaimedAccountSettings,
},
{
id: 'account-recovery-kit',
tabType: 'account_security',
sectionId: 'account',
label: RECOVERY_KIT_DESCRIPTOR,
keywords: [
RECOVERY_KIT_DESCRIPTOR,
RECOVERY_DESCRIPTOR,
LOST_PASSWORD_DESCRIPTOR,
PRINT_DESCRIPTOR,
PASSWORD_DESCRIPTOR,
],
description: CREATE_A_RECOVERY_KIT_DESCRIPTOR,
isVisible: shouldShowRecoveryKitSettings,
},
{
id: 'account-2fa',
tabType: 'account_security',
@@ -1,7 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Users from '@app/features/user/state/Users';
export function shouldShowClaimedAccountSettings(): boolean {
return Users.getCurrentUser()?.isClaimed() ?? true;
}
export function shouldShowEmailAccountSettings(): boolean {
return !RuntimeConfig.usesUsernameSignIn && shouldShowClaimedAccountSettings();
}
export function shouldShowRecoveryKitSettings(): boolean {
return RuntimeConfig.usesUsernameSignIn && shouldShowClaimedAccountSettings();
}