fix(app): follow username-only sign-in in every auth flow (#3432)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-12 20:25:57 +02:00
1 parent 30b32c9a35
commit 44799dafa2
8 files changed
+142 -53

No files matched your search

@@ -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
@@ -59,6 +59,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
}}
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}
@@ -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}