diff --git a/fluxer_app/src/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthFlow.tsx b/fluxer_app/src/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthFlow.tsx index 6e5c6553d..7476877af 100644 --- a/fluxer_app/src/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthFlow.tsx +++ b/fluxer_app/src/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthFlow.tsx @@ -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 ( + + ); + } return ( ); + } case AccountSwitcherAuthStep.LOGIN: return ( Create account} redirectPath="" + offerRecoveryKit={usernameSignIn} runtimeSnapshot={runtimeSnapshot} onRegister={onRegister} showLegalConsent diff --git a/fluxer_app/src/features/auth/components/pages/RecoverAccountPage.tsx b/fluxer_app/src/features/auth/components/pages/RecoverAccountPage.tsx index 2b3a9e230..16fa5200a 100644 --- a/fluxer_app/src/features/auth/components/pages/RecoverAccountPage.tsx +++ b/fluxer_app/src/features/auth/components/pages/RecoverAccountPage.tsx @@ -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; } -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(null); const [pendingKit, setPendingKit] = useState(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({

No recovery kit? Ask an admin of this instance for a password reset link.

+ {footerLink} + + + ); +}); + +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 ( + {i18n._(BACK_TO_SIGN_IN_DESCRIPTOR)} - - + } + onRecovered={handleRecovered} + data-flx="auth.recover-account-page.recover-account-form" + /> ); }); diff --git a/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx b/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx index c7090fc26..6d4e97925 100644 --- a/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx +++ b/fluxer_app/src/features/auth/flow/AuthLoginLayout.tsx @@ -903,23 +903,23 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({ }; const renderCredentialStep = (): ReactNode => { const forgotPasswordLink = - identifierField === 'login' ? ( - - {i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)} - - ) : ( - resolveForgotPasswordLink( - emailsEnabled, - forgotPasswordAction, - - {i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)} - , - ) - ); + identifierField === 'login' + ? (forgotPasswordAction ?? ( + + {i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)} + + )) + : resolveForgotPasswordLink( + emailsEnabled, + forgotPasswordAction, + + {i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)} + , + ); return ( ({ - 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: '', diff --git a/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx b/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx index 418bf6711..ddb44e781 100644 --- a/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx +++ b/fluxer_app/src/features/auth/flow/ResourceAuthPages.tsx @@ -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({ ); const runtimeSso = resolveAuthPanelSso(runtimeSnapshot); const sso = runtimeSso?.enabled === true && runtimeSso.enforced === true; + const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features); const header = ( <> ({ /> ) : ( ); } + const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features); return ( <>