Compare commits

...
16 changed files with 191 additions and 70 deletions

No files matched your search

@@ -17,14 +17,25 @@
color: var(--text-secondary);
}
.selectorRoot {
height: 45rem;
}
.selectorContent {
--selector-modal-content-height: clamp(20rem, 48vh, 23.75rem);
display: flex;
flex-direction: column;
padding: 0;
}
:global(html.mobile-layout) .selectorRoot {
height: auto;
}
:global(html.mobile-layout) .selectorContent {
--selector-modal-content-height: clamp(20rem, 48vh, 23.75rem);
height: var(--selector-modal-content-height);
min-height: var(--selector-modal-content-height);
max-height: var(--selector-modal-content-height);
padding: 0;
}
.listContainer {
@@ -2,6 +2,7 @@
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {AccountSwitcherAuthShell} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthShell';
import {
@@ -12,6 +13,7 @@ import {
import {AccountSwitcherLoginStep} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherLoginStep';
import {AccountSwitcherRegisterStep} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherRegisterStep';
import forgotStyles from '@app/features/auth/components/pages/ForgotPasswordPage.module.css';
import {RecoverAccountForm} from '@app/features/auth/components/pages/RecoverAccountPage';
import {shouldRenderInstanceStep} from '@app/features/auth/flow/auth_login_core/AuthLoginInstanceStep';
import {ForgotPasswordForm} from '@app/features/auth/flow/ForgotPasswordForm';
import MFAScreen from '@app/features/auth/flow/MfaScreen';
@@ -102,7 +104,7 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
const mfaMethods = Authentication.availableMfaMethods;
const hasMfaChallenge = mfaChallengePurpose === purpose && mfaTicket != null && mfaMethods != null;
let authStep: AccountSwitcherAuthStep;
if (hasMfaChallenge) {
if (hasMfaChallenge && selectedAuthStep !== AccountSwitcherAuthStep.FORGOT) {
authStep = AccountSwitcherAuthStep.MFA;
} else {
authStep = selectedAuthStep;
@@ -148,6 +150,14 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
},
[authRuntimeTarget, onLoginComplete, redirectAfterLogin],
);
const handleAccountRecovered = useCallback(
async (payload: LoginSuccessPayload) => {
const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget);
await AuthenticationCommands.completeLogin({...payload, runtimeSnapshot}, {redirectPath: redirectAfterLogin});
await onLoginComplete(payload);
},
[authRuntimeTarget, onLoginComplete, redirectAfterLogin],
);
const handleRegisterComplete = useCallback(
async (response: AuthenticationCommands.TokenResponse) => {
const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget);
@@ -168,7 +178,10 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
AuthenticationCommands.clearMfaTicket();
return;
case AccountSwitcherAuthStep.REGISTER:
showLoginStep();
return;
case AccountSwitcherAuthStep.FORGOT:
AuthenticationCommands.clearMfaTicket();
showLoginStep();
return;
case AccountSwitcherAuthStep.LOGIN:
@@ -203,14 +216,25 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.register-step"
/>
);
case AccountSwitcherAuthStep.FORGOT:
case AccountSwitcherAuthStep.FORGOT: {
const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget);
if (usesUsernameSignIn(runtimeSnapshot.features)) {
return (
<RecoverAccountForm
runtimeSnapshot={runtimeSnapshot}
onRecovered={handleAccountRecovered}
data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.recover-account-step"
/>
);
}
return (
<ForgotPasswordForm
runtimeSnapshot={requireSelectedRuntimeSnapshot(authRuntimeTarget)}
runtimeSnapshot={runtimeSnapshot}
renderContainer={renderForgotPasswordContainer}
data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.forgot-password-step"
/>
);
}
case AccountSwitcherAuthStep.LOGIN:
return (
<AccountSwitcherLoginStep
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
import type * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import authStyles from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthFlow.module.css';
import pageStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
@@ -35,6 +36,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
/>
);
}
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
return (
<>
<h1
@@ -49,13 +51,15 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
>
<AuthRegisterFormCore
fields={{
showEmail: true,
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
submitLabel={<Trans>Create account</Trans>}
redirectPath=""
offerRecoveryKit={usernameSignIn}
runtimeSnapshot={runtimeSnapshot}
onRegister={onRegister}
showLegalConsent
@@ -23,6 +23,7 @@ import {formatRecoveryKeyInput} from '@fluxer/constants/src/RecoveryKeyUtils';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type {ReactNode} from 'react';
import {useCallback, useEffect, useId, useState} from 'react';
const RECOVER_ACCOUNT_DESCRIPTOR = msg({
@@ -88,30 +89,28 @@ function showRecoveredKit(payload: LoginSuccessPayload, kit: PendingRecoveryKit)
rememberRecoveryKit(payload.userId, kit.createdAt);
}
interface RecoverAccountPageContentProps {
const EMPTY_RECOVERY_FRAGMENT: RecoveryFragment = {username: '', key: ''};
interface RecoverAccountFormProps {
readonly runtimeSnapshot: RuntimeConfigSnapshot;
readonly fragment?: RecoveryFragment;
readonly footerLink?: ReactNode;
readonly onRecovered: (payload: LoginSuccessPayload) => Promise<void>;
}
const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
export const RecoverAccountForm = observer(function RecoverAccountForm({
runtimeSnapshot,
}: RecoverAccountPageContentProps) {
fragment = EMPTY_RECOVERY_FRAGMENT,
footerLink,
onRecovered,
}: RecoverAccountFormProps) {
const {i18n} = useLingui();
const loginId = useId();
const keyId = useId();
const passwordId = useId();
const confirmPasswordId = useId();
const [fragment] = useState(readRecoveryFragment);
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
useEffect(() => {
if (!usernameSignIn) {
RouterUtils.replaceWith(Routes.LOGIN);
}
}, [usernameSignIn]);
const [mfaChallenge, setMfaChallenge] = useState<MfaChallenge | null>(null);
const [pendingKit, setPendingKit] = useState<PendingRecoveryKit | null>(null);
useEffect(() => {
clearRecoveryFragment();
}, []);
const {form, isLoading, error, fieldErrors} = useAuthForm({
initialValues: {
login: fragment.username,
@@ -137,21 +136,27 @@ const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
setMfaChallenge(result.challenge);
return false;
}
showRecoveredKit(result.payload, {...result.kit, login});
await AuthenticationCommands.completeLogin({...result.payload, runtimeSnapshot});
try {
await onRecovered(result.payload);
} finally {
showRecoveredKit(result.payload, {...result.kit, login});
}
return undefined;
},
firstFieldName: 'login',
});
const handleMfaSuccess = useCallback(
async (payload: LoginSuccessPayload) => {
if (pendingKit) {
showRecoveredKit(payload, pendingKit);
try {
await onRecovered(payload);
AuthenticationCommands.clearMfaTicket();
} finally {
if (pendingKit) {
showRecoveredKit(payload, pendingKit);
}
}
await AuthenticationCommands.completeLogin({...payload, runtimeSnapshot});
AuthenticationCommands.clearMfaTicket();
},
[pendingKit, runtimeSnapshot],
[onRecovered, pendingKit],
);
const handleMfaCancel = useCallback(() => {
AuthenticationCommands.clearMfaTicket();
@@ -265,11 +270,46 @@ const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
<p className={styles.hint} data-flx="auth.recover-account-page.no-kit-hint">
<Trans>No recovery kit? Ask an admin of this instance for a password reset link.</Trans>
</p>
{footerLink}
</div>
</>
);
});
interface RecoverAccountPageContentProps {
readonly runtimeSnapshot: RuntimeConfigSnapshot;
}
const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
runtimeSnapshot,
}: RecoverAccountPageContentProps) {
const {i18n} = useLingui();
const [fragment] = useState(readRecoveryFragment);
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
useEffect(() => {
if (!usernameSignIn) {
RouterUtils.replaceWith(Routes.LOGIN);
}
}, [usernameSignIn]);
useEffect(() => {
clearRecoveryFragment();
}, []);
const handleRecovered = useCallback(
(payload: LoginSuccessPayload) => AuthenticationCommands.completeLogin({...payload, runtimeSnapshot}),
[runtimeSnapshot],
);
return (
<RecoverAccountForm
runtimeSnapshot={runtimeSnapshot}
fragment={fragment}
footerLink={
<AuthRouterLink to={Routes.LOGIN} className={styles.link} data-flx="auth.recover-account-page.link">
{i18n._(BACK_TO_SIGN_IN_DESCRIPTOR)}
</AuthRouterLink>
</div>
</>
}
onRecovered={handleRecovered}
data-flx="auth.recover-account-page.recover-account-form"
/>
);
});
@@ -903,23 +903,23 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
};
const renderCredentialStep = (): ReactNode => {
const forgotPasswordLink =
identifierField === 'login' ? (
<AuthRouterLink
to={Routes.RECOVER_ACCOUNT}
className={styles.link}
data-flx="auth.flow.auth-login-layout.link.recover"
>
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
</AuthRouterLink>
) : (
resolveForgotPasswordLink(
emailsEnabled,
forgotPasswordAction,
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.flow.auth-login-layout.link">
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
</AuthRouterLink>,
)
);
identifierField === 'login'
? (forgotPasswordAction ?? (
<AuthRouterLink
to={Routes.RECOVER_ACCOUNT}
className={styles.link}
data-flx="auth.flow.auth-login-layout.link.recover"
>
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
</AuthRouterLink>
))
: resolveForgotPasswordLink(
emailsEnabled,
forgotPasswordAction,
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.flow.auth-login-layout.link">
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
</AuthRouterLink>,
);
return (
<AuthLoginCredentialStepPanel
extraTopContent={extraTopContent}
@@ -256,14 +256,14 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
...(userData ? {userData} : {}),
runtimeSnapshot,
});
if (offerRecoveryKit && showPassword && values.password) {
void createRecoveryKitWithPasswordAndOpen({
userId: response.user_id,
password: values.password,
username: response.user?.username,
discriminator: response.user?.discriminator,
});
}
}
if (offerRecoveryKit && showPassword && values.password) {
void createRecoveryKitWithPasswordAndOpen({
userId: response.user_id,
password: values.password,
username: response.user?.username,
discriminator: response.user?.discriminator,
});
}
onAuthenticated?.();
clearRegisterFormDraft(draftKey);
@@ -13,7 +13,7 @@ import {renderToStaticMarkup} from 'react-dom/server';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const mocks = vi.hoisted(() => ({
snapshot: {apiEndpoint: 'https://instance.example/api', apiCodeVersion: 7} as RuntimeConfigSnapshot,
snapshot: {apiEndpoint: 'https://instance.example/api', apiCodeVersion: 7, features: {}} as RuntimeConfigSnapshot,
reset: vi.fn(),
completeLogin: vi.fn(),
search: '',
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink';
import {AuthErrorState} from '@app/features/auth/flow/AuthErrorState';
@@ -163,6 +164,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
);
const runtimeSso = resolveAuthPanelSso(runtimeSnapshot);
const sso = runtimeSso?.enabled === true && runtimeSso.enforced === true;
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
const header = (
<>
<DesktopDeepLinkPrompt
@@ -185,9 +187,19 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
/>
) : (
<AuthRegisterFormCore
fields={{showUsername: false}}
fields={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
submitLabel={config.submitLabel}
redirectPath={config.registerRedirectPath(id, safeRedirect)}
offerRecoveryKit={usernameSignIn}
onRegister={onRegisterComplete}
runtimeSnapshot={runtimeSnapshot}
onAuthenticated={runtimeTarget.reset}
@@ -45,7 +45,12 @@ export const CreateDMModal = observer((props: CreateDMModalProps) => {
}
}, [modalLogic]);
return (
<Modal.Root size="small" centered data-flx="channel.create-dm-modal.modal-root">
<Modal.Root
size="small"
centered
className={selectorStyles.selectorRoot}
data-flx="channel.create-dm-modal.modal-root"
>
<Modal.Header title={i18n._(SELECT_FRIENDS_DESCRIPTOR)} data-flx="channel.create-dm-modal.modal-header">
{!restriction && (
<>
@@ -48,7 +48,12 @@ export const GiftSendToFriendModal = observer(function GiftSendToFriendModal({co
const handleCopy = useCopyLinkHandler(giftUrl, true);
const {sendingTo, sentTo, send: handleSendGift} = useSendLinkToRecipients(() => giftUrl);
return (
<Modal.Root size="small" centered data-flx="expressions.gift-send-to-friend-modal.modal-root">
<Modal.Root
size="small"
centered
className={selectorStyles.selectorRoot}
data-flx="expressions.gift-send-to-friend-modal.modal-root"
>
<Modal.Header
title={i18n._(YOU_ALREADY_HAVE_LIFETIME_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
data-flx="expressions.gift-send-to-friend-modal.modal-header"
@@ -326,7 +326,12 @@ const InviteModalContent = observer(function InviteModalContent({
return i18n._(YOUR_INVITE_LINK_EXPIRES_IN_SECONDS_DESCRIPTOR, {seconds});
};
return (
<Modal.Root size="small" centered data-flx="invite.invite-modal.modal-root--2">
<Modal.Root
size="small"
centered
className={selectorStyles.selectorRoot}
data-flx="invite.invite-modal.modal-root--2"
>
<Modal.Header
title={!showAdvanced ? title : i18n._(INVITE_LINK_SETTINGS_DESCRIPTOR)}
data-flx="invite.invite-modal.modal-header--2"
@@ -2,6 +2,7 @@
import {Routes} from '@app/app/Routes';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink';
import sharedStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
@@ -92,6 +93,7 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
</>
);
}
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
return (
<>
<DesktopDeepLinkPrompt
@@ -102,9 +104,19 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
<InviteHeader invite={invite} data-flx="invite.invite-register-page.invite-header" />
<div className={sharedStyles.container} data-flx="invite.invite-register-page.div--4">
<AuthRegisterFormCore
fields={{showUsername: false}}
fields={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
submitLabel={i18n._(CREATE_ACCOUNT_DESCRIPTOR)}
redirectPath="/"
offerRecoveryKit={usernameSignIn}
inviteCode={code}
runtimeSnapshot={runtimeSnapshot}
onAuthenticated={runtimeTarget.reset}
@@ -21,14 +21,6 @@
caret-color: var(--text-chat);
}
.destinationList {
display: flex;
flex-direction: column;
height: clamp(20rem, 48vh, 23.75rem);
min-height: 0;
max-height: clamp(20rem, 48vh, 23.75rem);
}
.footerSection {
position: relative;
display: flex;
@@ -361,6 +361,7 @@ export const ForwardModal = observer(
<Modal.Root
size="small"
centered
className={selectorStyles.selectorRoot}
initialFocusRef={shouldFocusSearch ? searchInputRef : undefined}
data-flx="messaging.forward-modal.modal-root"
>
@@ -388,7 +389,7 @@ export const ForwardModal = observer(
</Modal.Header>
<Modal.Content
padding="none"
className={modalStyles.destinationList}
className={selectorStyles.selectorContent}
data-flx="messaging.forward-modal.modal-content"
>
<div className={selectorStyles.listContainer} data-flx="messaging.forward-modal.div--2">
@@ -48,7 +48,12 @@ export const AddFriendsToGroupModal = observer((props: AddFriendsToGroupModalPro
? i18n._(LINK_HIDDEN_WHILE_SHARING_DESCRIPTOR)
: (modalLogic.inviteLink ?? '');
return (
<Modal.Root size="small" centered data-flx="relationship.add-friends-to-group-modal.modal-root">
<Modal.Root
size="small"
centered
className={selectorStyles.selectorRoot}
data-flx="relationship.add-friends-to-group-modal.modal-root"
>
<Modal.Header
title={i18n._(SELECT_FRIENDS_DESCRIPTOR)}
data-flx="relationship.add-friends-to-group-modal.modal-header"
@@ -144,7 +144,12 @@ export const ShareThemeModal = observer(({themeCss}: {themeCss: string}) => {
send: handleSendTheme,
} = useSendLinkToRecipients(() => (themeUrl ? `${i18n._(CHECK_OUT_MY_CUSTOM_THEME_DESCRIPTOR)}\n${themeUrl}` : null));
return (
<Modal.Root size="small" centered data-flx="theme.share-theme-modal.modal-root">
<Modal.Root
size="small"
centered
className={selectorStyles.selectorRoot}
data-flx="theme.share-theme-modal.modal-root"
>
<Modal.Header title={i18n._(SHARE_YOUR_THEME_DESCRIPTOR)} data-flx="theme.share-theme-modal.modal-header">
<div className={selectorStyles.headerSearch} data-flx="theme.share-theme-modal.div">
<Input