mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(auth): add username sign-in mode and recovery kits (#3215)
This commit is contained in:
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);
|
||||
|
||||
+19
-2
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+150
@@ -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>
|
||||
);
|
||||
});
|
||||
+12
-4
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+138
-3
@@ -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"
|
||||
>
|
||||
|
||||
+31
-7
@@ -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)},
|
||||
|
||||
+37
-2
@@ -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',
|
||||
|
||||
+9
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user