mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): follow username-only sign-in in every auth flow (#3432)
This commit is contained in:
1 parent
30b32c9a35
commit
44799dafa2
8 files changed
+142
-53
No files matched your search
+27
-3
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user