fix(auth): fix approval signups and auto-join invites (#3467)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 16:41:33 +02:00
1 parent e403fb6cce
commit d5ca5d8293
64 files changed
+622 -72

No files matched your search

@@ -5,6 +5,7 @@ import {SetupWelcomeGate} from '@app/features/app/components/setup/SetupWelcomeG
import type {SetupBrandingAssetKind} from '@app/features/app/components/setup/SetupWizardClient';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import {Button} from '@app/features/ui/button/Button';
import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerField';
import {Input} from '@app/features/ui/components/form/FormInput';
@@ -751,13 +752,7 @@ export const AdminAccountStep = observer(
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
redirectPath=""
runtimeSnapshot={RuntimeConfig.getSnapshot()}
@@ -7,6 +7,7 @@ import authStyles from '@app/features/auth/components/accounts/account_switcher_
import pageStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
import {AuthSsoPanel, isSsoLoginEnforced, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
import Theme from '@app/features/theme/state/Theme';
import {flxElementClassName} from '@app/lib/react';
@@ -50,13 +51,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
data-flx="auth.accounts.account-switcher-modal.account-switcher-register-step.container"
>
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={<Trans>Create account</Trans>}
redirectPath=""
offerRecoveryKit={usernameSignIn}
@@ -11,6 +11,7 @@ import {AUTH_LOGIN_METHOD_QUERY_PARAM} from '@app/features/auth/flow/auth_login_
import {AuthClientPermissionStep} from '@app/features/auth/flow/client_intro/AuthClientPermissionStep';
import {AuthClientPreferencesStep} from '@app/features/auth/flow/client_intro/AuthClientPreferencesStep';
import {useDesktopClientIntroFlow} from '@app/features/auth/flow/client_intro/useDesktopClientIntroFlow';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
@@ -116,13 +117,7 @@ const RegisterPageContent = observer(function RegisterPageContent({runtimeSnapsh
</h1>
<div className={sharedStyles.container} data-flx="auth.register-page.register-page-content.div">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={<Trans>Create account</Trans>}
redirectPath={redirectTo}
offerRecoveryKit={usernameSignIn}
@@ -7,6 +7,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
import FormField from '@app/features/auth/flow/AuthFormField';
import styles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {DateOfBirthField} from '@app/features/auth/flow/DateOfBirthField';
import {type RegisterFormFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
import {
getRegistrationLegalConsentConfig,
RegistrationLegalConsent,
@@ -83,17 +84,8 @@ const USERNAME_AVAILABLE_DESCRIPTOR = msg({
const MAX_USERNAME_LENGTH = 32;
const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;
interface FieldConfig {
showEmail?: boolean;
showPassword?: boolean;
showPasswordConfirmation?: boolean;
showUsername?: boolean;
showUsernameValidation?: boolean;
requireUsername?: boolean;
}
interface AuthRegisterFormCoreProps {
fields?: FieldConfig;
fields?: RegisterFormFields;
submitLabel: React.ReactNode;
redirectPath: string;
runtimeSnapshot: RuntimeConfigSnapshot;
@@ -136,10 +128,10 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
const firstFieldName = showEmail ? 'email' : requireUsername ? 'username' : 'global_name';
const location = useLocation();
const draftKey = `register:${location.pathname}${location.search}`;
const registrationUrlCode = useMemo(() => {
const value = new URLSearchParams(location.search).get('registration_url')?.trim();
return value || undefined;
}, [location.search]);
const registrationUrlCode = useMemo(
() => readRegistrationUrlCode(new URLSearchParams(location.search)),
[location.search],
);
const registrationTarget = useMemo(() => instanceTargetFromSnapshot(runtimeSnapshot), [runtimeSnapshot]);
const isPublicRegistrationClosed = runtimeSnapshot.registration.mode === 'closed' && !registrationUrlCode;
const collectDateOfBirth = runtimeSnapshot.appPublic.registration.collect_date_of_birth;
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {
credentialRegisterFields,
linkRegisterFields,
readRegistrationUrlCode,
} from '@app/features/auth/flow/RegisterFormFields';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import type {InstanceRegistration} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {describe, expect, it} from 'vitest';
function snapshot(usernameSignIn: boolean, mode: InstanceRegistration['mode']): RuntimeConfigSnapshot {
return {
features: {account_identity: usernameSignIn ? AccountIdentityModes.USERNAME : AccountIdentityModes.EMAIL},
registration: {mode, admin_registration_urls_enabled: false},
} as RuntimeConfigSnapshot;
}
const EMAIL_CREDENTIALS = {
showEmail: true,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: false,
};
const USERNAME_CREDENTIALS = {
showEmail: false,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
};
describe('register form fields', () => {
it('asks for credentials on the full register form in both sign-in modes', () => {
expect(credentialRegisterFields(false)).toEqual(EMAIL_CREDENTIALS);
expect(credentialRegisterFields(true)).toEqual(USERNAME_CREDENTIALS);
});
it('keeps link sign-ups minimal only where the account can sign in right away', () => {
expect(linkRegisterFields(snapshot(false, 'open'), undefined)).toEqual({showUsername: false});
expect(linkRegisterFields(snapshot(false, 'closed'), undefined)).toEqual({showUsername: false});
expect(linkRegisterFields(snapshot(false, 'approval'), undefined)).toEqual(EMAIL_CREDENTIALS);
});
it('asks for credentials on link sign-ups through a registration URL, which can require approval', () => {
const code = readRegistrationUrlCode(new URLSearchParams('?registration_url=%20abc%20'));
expect(code).toBe('abc');
for (const mode of ['open', 'approval', 'closed'] as const) {
expect(linkRegisterFields(snapshot(false, mode), code)).toEqual(EMAIL_CREDENTIALS);
}
expect(readRegistrationUrlCode(new URLSearchParams('?registration_url=%20'))).toBeUndefined();
});
it('always asks for a username and password on link sign-ups of a username instance', () => {
for (const mode of ['open', 'approval', 'closed'] as const) {
expect(linkRegisterFields(snapshot(true, mode), undefined)).toEqual(USERNAME_CREDENTIALS);
}
});
});
@@ -0,0 +1,38 @@
// 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';
export interface RegisterFormFields {
showEmail?: boolean;
showPassword?: boolean;
showPasswordConfirmation?: boolean;
showUsername?: boolean;
showUsernameValidation?: boolean;
requireUsername?: boolean;
}
export function credentialRegisterFields(usernameSignIn: boolean): RegisterFormFields {
return {
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
};
}
export function readRegistrationUrlCode(params: URLSearchParams): string | undefined {
return params.get('registration_url')?.trim() || undefined;
}
export function linkRegisterFields(
snapshot: RuntimeConfigSnapshot,
registrationUrlCode: string | undefined,
): RegisterFormFields {
const usernameSignIn = usesUsernameSignIn(snapshot.features);
if (usernameSignIn || registrationUrlCode || snapshot.registration.mode === 'approval') {
return credentialRegisterFields(usernameSignIn);
}
return {showUsername: false};
}
@@ -13,7 +13,12 @@ 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, features: {}} as RuntimeConfigSnapshot,
snapshot: {
apiEndpoint: 'https://instance.example/api',
apiCodeVersion: 7,
features: {},
registration: {mode: 'open', admin_registration_urls_enabled: false},
} as RuntimeConfigSnapshot,
reset: vi.fn(),
completeLogin: vi.fn(),
search: '',
@@ -110,6 +115,7 @@ beforeEach(() => {
mocks.search = '';
mocks.loginProps = mocks.registerProps = mocks.ssoProps = mocks.carouselProps = null;
delete (mocks.snapshot as any).sso;
(mocks.snapshot as any).registration = {mode: 'open', admin_registration_urls_enabled: false};
});
describe('resource auth guards', () => {
it('blocks redeemed resources before login, registration or enforced SSO', () => {
@@ -207,4 +213,21 @@ describe('resource auth guards', () => {
expect(mocks.ssoProps.redirectPath).toBe('/gift/code/register');
expect(mocks.registerProps).toBeNull();
});
it('asks for an email and password on an approval instance', () => {
(mocks.snapshot as any).registration = {mode: 'approval', admin_registration_urls_enabled: false};
renderToStaticMarkup(
<ResourceRegisterPage id="code" config={config()} data-flx="auth.flow.resource-auth-pages-test.code--8" />,
);
expect(mocks.registerProps.fields).toMatchObject({
showEmail: true,
showPassword: true,
showPasswordConfirmation: true,
});
});
it('keeps the minimal form on an open email instance', () => {
renderToStaticMarkup(
<ResourceRegisterPage id="code" config={config()} data-flx="auth.flow.resource-auth-pages-test.code--9" />,
);
expect(mocks.registerProps.fields).toEqual({showUsername: false});
});
});
@@ -17,6 +17,7 @@ import {AuthRuntimeTargetResetAction} from '@app/features/auth/flow/AuthRuntimeT
import {AuthSsoPanel, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt';
import {linkRegisterFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
@@ -188,16 +189,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
/>
) : (
<AuthRegisterFormCore
fields={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
fields={linkRegisterFields(runtimeSnapshot, readRegistrationUrlCode(params))}
submitLabel={config.submitLabel}
redirectPath={config.registerRedirectPath(id, safeRedirect)}
offerRecoveryKit={usernameSignIn}
@@ -10,6 +10,7 @@ import {AuthRuntimeTargetGate} from '@app/features/auth/flow/AuthRuntimeTargetGa
import {AuthSsoPanel, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt';
import {InviteHeader} from '@app/features/auth/flow/InviteHeader';
import {linkRegisterFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
@@ -104,16 +105,7 @@ 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={
usernameSignIn
? {
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: true,
}
: {showUsername: false}
}
fields={linkRegisterFields(runtimeSnapshot, readRegistrationUrlCode(params))}
submitLabel={i18n._(CREATE_ACCOUNT_DESCRIPTOR)}
redirectPath="/"
offerRecoveryKit={usernameSignIn}