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 type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; 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 * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {AccountSwitcherAuthShell} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthShell'; import {AccountSwitcherAuthShell} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthShell';
import { import {
@@ -12,6 +13,7 @@ import {
import {AccountSwitcherLoginStep} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherLoginStep'; 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 {AccountSwitcherRegisterStep} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherRegisterStep';
import forgotStyles from '@app/features/auth/components/pages/ForgotPasswordPage.module.css'; 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 {shouldRenderInstanceStep} from '@app/features/auth/flow/auth_login_core/AuthLoginInstanceStep';
import {ForgotPasswordForm} from '@app/features/auth/flow/ForgotPasswordForm'; import {ForgotPasswordForm} from '@app/features/auth/flow/ForgotPasswordForm';
import MFAScreen from '@app/features/auth/flow/MfaScreen'; import MFAScreen from '@app/features/auth/flow/MfaScreen';
@@ -102,7 +104,7 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
const mfaMethods = Authentication.availableMfaMethods; const mfaMethods = Authentication.availableMfaMethods;
const hasMfaChallenge = mfaChallengePurpose === purpose && mfaTicket != null && mfaMethods != null; const hasMfaChallenge = mfaChallengePurpose === purpose && mfaTicket != null && mfaMethods != null;
let authStep: AccountSwitcherAuthStep; let authStep: AccountSwitcherAuthStep;
if (hasMfaChallenge) { if (hasMfaChallenge && selectedAuthStep !== AccountSwitcherAuthStep.FORGOT) {
authStep = AccountSwitcherAuthStep.MFA; authStep = AccountSwitcherAuthStep.MFA;
} else { } else {
authStep = selectedAuthStep; authStep = selectedAuthStep;
@@ -148,6 +150,14 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
}, },
[authRuntimeTarget, onLoginComplete, redirectAfterLogin], [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( const handleRegisterComplete = useCallback(
async (response: AuthenticationCommands.TokenResponse) => { async (response: AuthenticationCommands.TokenResponse) => {
const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget); const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget);
@@ -168,7 +178,10 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
AuthenticationCommands.clearMfaTicket(); AuthenticationCommands.clearMfaTicket();
return; return;
case AccountSwitcherAuthStep.REGISTER: case AccountSwitcherAuthStep.REGISTER:
showLoginStep();
return;
case AccountSwitcherAuthStep.FORGOT: case AccountSwitcherAuthStep.FORGOT:
AuthenticationCommands.clearMfaTicket();
showLoginStep(); showLoginStep();
return; return;
case AccountSwitcherAuthStep.LOGIN: 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" 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 ( return (
<ForgotPasswordForm <ForgotPasswordForm
runtimeSnapshot={requireSelectedRuntimeSnapshot(authRuntimeTarget)} runtimeSnapshot={runtimeSnapshot}
renderContainer={renderForgotPasswordContainer} renderContainer={renderForgotPasswordContainer}
data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.forgot-password-step" data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.forgot-password-step"
/> />
); );
}
case AccountSwitcherAuthStep.LOGIN: case AccountSwitcherAuthStep.LOGIN:
return ( return (
<AccountSwitcherLoginStep <AccountSwitcherLoginStep
@@ -59,6 +59,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
}} }}
submitLabel={<Trans>Create account</Trans>} submitLabel={<Trans>Create account</Trans>}
redirectPath="" redirectPath=""
offerRecoveryKit={usernameSignIn}
runtimeSnapshot={runtimeSnapshot} runtimeSnapshot={runtimeSnapshot}
onRegister={onRegister} onRegister={onRegister}
showLegalConsent showLegalConsent
@@ -23,6 +23,7 @@ import {formatRecoveryKeyInput} from '@fluxer/constants/src/RecoveryKeyUtils';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type {ReactNode} from 'react';
import {useCallback, useEffect, useId, useState} from 'react'; import {useCallback, useEffect, useId, useState} from 'react';
const RECOVER_ACCOUNT_DESCRIPTOR = msg({ const RECOVER_ACCOUNT_DESCRIPTOR = msg({
@@ -88,30 +89,28 @@ function showRecoveredKit(payload: LoginSuccessPayload, kit: PendingRecoveryKit)
rememberRecoveryKit(payload.userId, kit.createdAt); rememberRecoveryKit(payload.userId, kit.createdAt);
} }
interface RecoverAccountPageContentProps { const EMPTY_RECOVERY_FRAGMENT: RecoveryFragment = {username: '', key: ''};
interface RecoverAccountFormProps {
readonly runtimeSnapshot: RuntimeConfigSnapshot; 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, runtimeSnapshot,
}: RecoverAccountPageContentProps) { fragment = EMPTY_RECOVERY_FRAGMENT,
footerLink,
onRecovered,
}: RecoverAccountFormProps) {
const {i18n} = useLingui(); const {i18n} = useLingui();
const loginId = useId(); const loginId = useId();
const keyId = useId(); const keyId = useId();
const passwordId = useId(); const passwordId = useId();
const confirmPasswordId = 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 [mfaChallenge, setMfaChallenge] = useState<MfaChallenge | null>(null);
const [pendingKit, setPendingKit] = useState<PendingRecoveryKit | null>(null); const [pendingKit, setPendingKit] = useState<PendingRecoveryKit | null>(null);
useEffect(() => {
clearRecoveryFragment();
}, []);
const {form, isLoading, error, fieldErrors} = useAuthForm({ const {form, isLoading, error, fieldErrors} = useAuthForm({
initialValues: { initialValues: {
login: fragment.username, login: fragment.username,
@@ -137,21 +136,27 @@ const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
setMfaChallenge(result.challenge); setMfaChallenge(result.challenge);
return false; return false;
} }
showRecoveredKit(result.payload, {...result.kit, login}); try {
await AuthenticationCommands.completeLogin({...result.payload, runtimeSnapshot}); await onRecovered(result.payload);
} finally {
showRecoveredKit(result.payload, {...result.kit, login});
}
return undefined; return undefined;
}, },
firstFieldName: 'login', firstFieldName: 'login',
}); });
const handleMfaSuccess = useCallback( const handleMfaSuccess = useCallback(
async (payload: LoginSuccessPayload) => { async (payload: LoginSuccessPayload) => {
if (pendingKit) { try {
showRecoveredKit(payload, pendingKit); 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(() => { const handleMfaCancel = useCallback(() => {
AuthenticationCommands.clearMfaTicket(); AuthenticationCommands.clearMfaTicket();
@@ -265,11 +270,46 @@ const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
<p className={styles.hint} data-flx="auth.recover-account-page.no-kit-hint"> <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> <Trans>No recovery kit? Ask an admin of this instance for a password reset link.</Trans>
</p> </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"> <AuthRouterLink to={Routes.LOGIN} className={styles.link} data-flx="auth.recover-account-page.link">
{i18n._(BACK_TO_SIGN_IN_DESCRIPTOR)} {i18n._(BACK_TO_SIGN_IN_DESCRIPTOR)}
</AuthRouterLink> </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 renderCredentialStep = (): ReactNode => {
const forgotPasswordLink = const forgotPasswordLink =
identifierField === 'login' ? ( identifierField === 'login'
<AuthRouterLink ? (forgotPasswordAction ?? (
to={Routes.RECOVER_ACCOUNT} <AuthRouterLink
className={styles.link} to={Routes.RECOVER_ACCOUNT}
data-flx="auth.flow.auth-login-layout.link.recover" className={styles.link}
> data-flx="auth.flow.auth-login-layout.link.recover"
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)} >
</AuthRouterLink> {i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
) : ( </AuthRouterLink>
resolveForgotPasswordLink( ))
emailsEnabled, : resolveForgotPasswordLink(
forgotPasswordAction, emailsEnabled,
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.flow.auth-login-layout.link"> forgotPasswordAction,
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)} <AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.flow.auth-login-layout.link">
</AuthRouterLink>, {i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
) </AuthRouterLink>,
); );
return ( return (
<AuthLoginCredentialStepPanel <AuthLoginCredentialStepPanel
extraTopContent={extraTopContent} extraTopContent={extraTopContent}
@@ -256,14 +256,14 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
...(userData ? {userData} : {}), ...(userData ? {userData} : {}),
runtimeSnapshot, runtimeSnapshot,
}); });
if (offerRecoveryKit && showPassword && values.password) { }
void createRecoveryKitWithPasswordAndOpen({ if (offerRecoveryKit && showPassword && values.password) {
userId: response.user_id, void createRecoveryKitWithPasswordAndOpen({
password: values.password, userId: response.user_id,
username: response.user?.username, password: values.password,
discriminator: response.user?.discriminator, username: response.user?.username,
}); discriminator: response.user?.discriminator,
} });
} }
onAuthenticated?.(); onAuthenticated?.();
clearRegisterFormDraft(draftKey); clearRegisterFormDraft(draftKey);
@@ -13,7 +13,7 @@ import {renderToStaticMarkup} from 'react-dom/server';
import {beforeEach, describe, expect, it, vi} from 'vitest'; import {beforeEach, describe, expect, it, vi} from 'vitest';
const mocks = vi.hoisted(() => ({ 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(), reset: vi.fn(),
completeLogin: vi.fn(), completeLogin: vi.fn(),
search: '', search: '',
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; 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 * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink'; import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink';
import {AuthErrorState} from '@app/features/auth/flow/AuthErrorState'; import {AuthErrorState} from '@app/features/auth/flow/AuthErrorState';
@@ -163,6 +164,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
); );
const runtimeSso = resolveAuthPanelSso(runtimeSnapshot); const runtimeSso = resolveAuthPanelSso(runtimeSnapshot);
const sso = runtimeSso?.enabled === true && runtimeSso.enforced === true; const sso = runtimeSso?.enabled === true && runtimeSso.enforced === true;
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
const header = ( const header = (
<> <>
<DesktopDeepLinkPrompt <DesktopDeepLinkPrompt
@@ -185,9 +187,19 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
/> />
) : ( ) : (
<AuthRegisterFormCore <AuthRegisterFormCore
fields={{showUsername: false}} fields={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
submitLabel={config.submitLabel} submitLabel={config.submitLabel}
redirectPath={config.registerRedirectPath(id, safeRedirect)} redirectPath={config.registerRedirectPath(id, safeRedirect)}
offerRecoveryKit={usernameSignIn}
onRegister={onRegisterComplete} onRegister={onRegisterComplete}
runtimeSnapshot={runtimeSnapshot} runtimeSnapshot={runtimeSnapshot}
onAuthenticated={runtimeTarget.reset} onAuthenticated={runtimeTarget.reset}
@@ -2,6 +2,7 @@
import {Routes} from '@app/app/Routes'; import {Routes} from '@app/app/Routes';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; 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 {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink';
import sharedStyles from '@app/features/auth/flow/AuthPageStyles.module.css'; import sharedStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore'; import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
@@ -92,6 +93,7 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
</> </>
); );
} }
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
return ( return (
<> <>
<DesktopDeepLinkPrompt <DesktopDeepLinkPrompt
@@ -102,9 +104,19 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
<InviteHeader invite={invite} data-flx="invite.invite-register-page.invite-header" /> <InviteHeader invite={invite} data-flx="invite.invite-register-page.invite-header" />
<div className={sharedStyles.container} data-flx="invite.invite-register-page.div--4"> <div className={sharedStyles.container} data-flx="invite.invite-register-page.div--4">
<AuthRegisterFormCore <AuthRegisterFormCore
fields={{showUsername: false}} fields={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
submitLabel={i18n._(CREATE_ACCOUNT_DESCRIPTOR)} submitLabel={i18n._(CREATE_ACCOUNT_DESCRIPTOR)}
redirectPath="/" redirectPath="/"
offerRecoveryKit={usernameSignIn}
inviteCode={code} inviteCode={code}
runtimeSnapshot={runtimeSnapshot} runtimeSnapshot={runtimeSnapshot}
onAuthenticated={runtimeTarget.reset} onAuthenticated={runtimeTarget.reset}