mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(auth): fix approval signups and auto-join invites (#3467)
This commit is contained in:
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()}
|
||||
|
||||
+2
-7
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user