diff --git a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx index 648c6e22b..ad134834a 100644 --- a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx +++ b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx @@ -776,29 +776,11 @@ export const SelfHostedSetupWizardGate = observer(() => { setSetupTokenError(null); }, []); - const submittedSetupToken = setupTokenRequired ? setupToken.trim() : undefined; + const submittedSetupToken = setupToken.trim() || undefined; - const handleSetupTokenRejected = useCallback(async (message: string) => { + const handleSetupTokenRejected = useCallback((message: string) => { setSetupTokenError(message); - try { - await RuntimeConfig.refreshDiscovery(); - } catch (error) { - logger.warn('Failed to refresh discovery after the setup token was rejected', error); - } - const required = RuntimeConfig.appPublic.setup.setup_token_required === true; - setWizardSnapshot((current) => - transitionSetupWizardSnapshot( - transitionSetupWizardSnapshot(current, { - type: 'wizard.sync', - isAuthenticated: current.context.isAuthenticated, - hasConfig: current.context.hasConfig, - accountIdentity: current.context.accountIdentity, - identityLocked: current.context.identityLocked, - setupTokenRequired: required, - }), - {type: 'wizard.goto', step: 'setup_token'}, - ), - ); + setWizardSnapshot((current) => transitionSetupWizardSnapshot(current, {type: 'wizard.setupTokenRejected'})); }, []); const submitSignInMethod = useCallback(async () => { @@ -816,7 +798,7 @@ export const SelfHostedSetupWizardGate = observer(() => { (violation) => violation.path === 'setup_token', ); if (setupTokenViolation) { - await handleSetupTokenRejected(setupTokenViolation.message); + handleSetupTokenRejected(setupTokenViolation.message); setSavingSignInMethod(false); return; } diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.test.ts b/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.test.ts new file mode 100644 index 000000000..a1973826a --- /dev/null +++ b/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.test.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + createSetupWizardSnapshot, + type SetupWizardSnapshot, + selectSetupWizardModel, + transitionSetupWizardSnapshot, +} from '@app/features/app/components/setup/SetupWizardStateMachine'; +import {runtimeSnapshotFromDiscovery} from '@app/features/app/state/InstanceSnapshotStore'; +import {instanceDiscoveryFixture} from '@app/features/platform/state/__fixtures__/RuntimeBootstrapFixture'; +import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants'; +import {parseInstanceDiscoveryDocument} from '@fluxer/instance_bootstrap/src/Discovery'; +import {describe, expect, test} from 'vitest'; + +function registerSnapshot(setupTokenRequired: boolean): SetupWizardSnapshot { + return transitionSetupWizardSnapshot(createSetupWizardSnapshot(), { + type: 'wizard.sync', + isAuthenticated: false, + hasConfig: false, + accountIdentity: AccountIdentityModes.USERNAME, + identityLocked: false, + setupTokenRequired, + }); +} + +describe('setup wizard setup token step', () => { + test('follows the welcome step when the discovery document requires the token', () => { + const wire = instanceDiscoveryFixture('https://chat.example.test/api'); + wire.app_public = { + ...wire.app_public, + setup: {configured: false, admin_url: null, account_identity_locked: false, setup_token_required: true}, + }; + const setup = runtimeSnapshotFromDiscovery(parseInstanceDiscoveryDocument(wire)).appPublic.setup; + expect(setup.setup_token_required).toBe(true); + expect(selectSetupWizardModel(registerSnapshot(setup.setup_token_required === true)).steps).toEqual([ + 'welcome', + 'setup_token', + 'theme', + 'sign_in_method', + 'admin_intro', + 'admin_account', + ]); + }); + + test('opens after a rejection while the client believes no token is required', () => { + let snapshot = registerSnapshot(false); + snapshot = transitionSetupWizardSnapshot(snapshot, {type: 'wizard.next'}); + snapshot = transitionSetupWizardSnapshot(snapshot, {type: 'wizard.next'}); + expect(selectSetupWizardModel(snapshot).step).toBe('sign_in_method'); + snapshot = transitionSetupWizardSnapshot(snapshot, {type: 'wizard.setupTokenRejected'}); + snapshot = transitionSetupWizardSnapshot(snapshot, { + type: 'wizard.sync', + isAuthenticated: false, + hasConfig: false, + accountIdentity: AccountIdentityModes.USERNAME, + identityLocked: false, + setupTokenRequired: false, + }); + const model = selectSetupWizardModel(snapshot); + expect(model.step).toBe('setup_token'); + expect(model.steps[model.stepIndex + 1]).toBe('theme'); + }); +}); diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts b/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts index 932be4ae3..7c30c6c4c 100644 --- a/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts +++ b/fluxer_app/src/features/app/components/setup/SetupWizardStateMachine.ts @@ -66,6 +66,7 @@ const USERNAME_CONFIGURE_STEPS: ReadonlyArray = [ function phaseSteps( phase: WizardPhase, + currentStep: WizardStep, accountIdentity: AccountIdentityMode, identityLocked: boolean, setupTokenRequired: boolean, @@ -73,7 +74,9 @@ function phaseSteps( switch (phase) { case 'register': return REGISTER_STEPS.filter( - (step) => (step !== 'sign_in_method' || !identityLocked) && (step !== 'setup_token' || setupTokenRequired), + (step) => + step === currentStep || + ((step !== 'sign_in_method' || !identityLocked) && (step !== 'setup_token' || setupTokenRequired)), ); case 'loading': return LOADING_STEPS; @@ -90,12 +93,13 @@ interface SetupWizardMachineContext { accountIdentity: AccountIdentityMode; identityLocked: boolean; setupTokenRequired: boolean; + setupTokenRejected: boolean; } export type SetupWizardMachineEvent = | {type: 'wizard.next'} | {type: 'wizard.back'} - | {type: 'wizard.goto'; step: WizardStep} + | {type: 'wizard.setupTokenRejected'} | { type: 'wizard.sync'; isAuthenticated: boolean; @@ -116,9 +120,10 @@ export interface SetupWizardModel { function contextSteps(context: SetupWizardMachineContext): ReadonlyArray { return phaseSteps( derivePhase(context.isAuthenticated, context.hasConfig), + context.step, context.accountIdentity, context.identityLocked, - context.setupTokenRequired, + context.setupTokenRequired || context.setupTokenRejected, ); } @@ -130,13 +135,6 @@ function advance(context: SetupWizardMachineContext, delta: number): Partial { - const steps = contextSteps(context); - const targetIndex = steps.indexOf(step); - if (targetIndex < 0) return {}; - return {step, direction: Math.sign(targetIndex - steps.indexOf(context.step))}; -} - function targetsPhase(event: SetupWizardMachineEvent, context: SetupWizardMachineContext, phase: WizardPhase): boolean { if (event.type !== 'wizard.sync') return false; return ( @@ -169,7 +167,7 @@ const setupWizardStateMachine = setup({ }), goNext: assign(({context}) => advance(context, 1)), goBack: assign(({context}) => advance(context, -1)), - goTo: assign(({context, event}) => (event.type === 'wizard.goto' ? jump(context, event.step) : {})), + requireSetupToken: assign({setupTokenRejected: () => true, step: () => 'setup_token', direction: () => -1}), enterRegister: assign({step: () => REGISTER_STEPS[0], direction: () => 0}), enterLoading: assign({step: () => LOADING_STEPS[0], direction: () => 0}), enterConfigure: assign({step: () => CONFIGURE_STEPS[0], direction: () => 0}), @@ -184,6 +182,7 @@ const setupWizardStateMachine = setup({ accountIdentity: AccountIdentityModes.EMAIL, identityLocked: false, setupTokenRequired: false, + setupTokenRejected: false, }, initial: 'register', on: { @@ -200,7 +199,7 @@ const setupWizardStateMachine = setup({ on: { 'wizard.next': {actions: 'goNext'}, 'wizard.back': {actions: 'goBack'}, - 'wizard.goto': {actions: 'goTo'}, + 'wizard.setupTokenRejected': {actions: 'requireSetupToken'}, }, }, loading: { diff --git a/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts b/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts index 6c16570dd..966ce5df1 100644 --- a/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts +++ b/fluxer_app/src/features/app/state/InstanceSnapshotStore.test.ts @@ -23,6 +23,7 @@ import { import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types'; import {computeWireFormat} from '@fluxer/limits/src/LimitDiffer'; import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes'; +import {InstanceAppPublicSchema} from '@fluxer/schema/src/domains/instance/InstanceSchemas'; import {runInAction} from 'mobx'; import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; @@ -217,6 +218,45 @@ describe('runtimeSnapshotFromDiscovery', () => { expect(snapshot.appPublic.legal.terms_url).toBe('https://d.test/legal?doc=terms'); }); + test('keeps every public app field the discovery schema defines', () => { + const document = discoveryDocument('q.test'); + document.app_public = { + branding: { + product_name: 'Fluxer q.test', + icon_url: 'https://cdn.q.test/icon.png', + symbol_url: 'https://cdn.q.test/symbol.png', + logo_url: 'https://cdn.q.test/logo.png', + wordmark_url: 'https://cdn.q.test/wordmark.png', + favicon_url: 'https://cdn.q.test/favicon.png', + theme_color: '#4641d9', + status_page_url: 'https://status.q.test', + status_page_incident_history_url: 'https://status.q.test/history', + premium_product_name: 'Supporter', + premium_info_url: 'https://q.test/premium', + }, + setup: { + configured: false, + admin_url: 'https://q.test/admin', + account_identity_locked: true, + setup_token_required: true, + }, + legal: { + terms_url: 'https://q.test/terms', + privacy_url: 'https://q.test/privacy', + guidelines_url: 'https://q.test/guidelines', + }, + registration: {collect_date_of_birth: true}, + }; + const snapshot = runtimeSnapshotFromDiscovery(parseInstanceDiscoveryDocument(document)); + expect(snapshot.appPublic).toEqual(document.app_public); + expect(requireRuntimeConfigSnapshot(JSON.parse(JSON.stringify(snapshot))).appPublic).toEqual(document.app_public); + for (const section of ['branding', 'setup', 'legal', 'registration'] as const) { + expect(Object.keys(snapshot.appPublic[section]).sort()).toEqual( + Object.keys(InstanceAppPublicSchema.shape[section].shape).sort(), + ); + } + }); + test('rejects a branding URL that is not an absolute HTTP URL', () => { const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('e.test')); expect(() => diff --git a/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts b/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts index af441d8fa..97d332481 100644 --- a/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts +++ b/fluxer_app/src/features/app/state/RuntimeConfigSnapshot.ts @@ -399,6 +399,11 @@ function readAppPublic(value: unknown): InstanceAppPublic { const setup = readRecord(source.setup, 'appPublic.setup'); const legal = readRecord(source.legal, 'appPublic.legal'); const registration = readRecord(source.registration, 'appPublic.registration'); + const accountIdentityLocked = readOptionalBoolean( + setup, + 'account_identity_locked', + 'appPublic.setup.account_identity_locked', + ); const setupTokenRequired = readOptionalBoolean(setup, 'setup_token_required', 'appPublic.setup.setup_token_required'); return { branding: { @@ -419,6 +424,7 @@ function readAppPublic(value: unknown): InstanceAppPublic { setup: { configured: readBoolean(setup, 'configured', 'appPublic.setup.configured'), admin_url: readNullableAbsoluteHttpUrl(setup, 'admin_url', 'appPublic.setup.admin_url'), + ...(accountIdentityLocked === undefined ? {} : {account_identity_locked: accountIdentityLocked}), ...(setupTokenRequired === undefined ? {} : {setup_token_required: setupTokenRequired}), }, legal: { diff --git a/packages/instance_bootstrap/src/ClientRuntimeDiscovery.ts b/packages/instance_bootstrap/src/ClientRuntimeDiscovery.ts index 86b5fefc2..19a7810bd 100644 --- a/packages/instance_bootstrap/src/ClientRuntimeDiscovery.ts +++ b/packages/instance_bootstrap/src/ClientRuntimeDiscovery.ts @@ -133,6 +133,7 @@ function projectAppPublic(appPublic: InstanceDiscoveryDocument['appPublic']): In premium_info_url: value.branding.premium_info_url ?? null, }, setup: { + ...value.setup, configured: requireDefined(value.setup.configured, 'app_public.setup.configured'), admin_url: requireDefined(value.setup.admin_url, 'app_public.setup.admin_url'), }, diff --git a/packages/instance_bootstrap/src/Discovery.ts b/packages/instance_bootstrap/src/Discovery.ts index 3cb2720a4..34765b142 100644 --- a/packages/instance_bootstrap/src/Discovery.ts +++ b/packages/instance_bootstrap/src/Discovery.ts @@ -75,6 +75,16 @@ const BRANDING_URL_KEYS = Object.freeze([ 'premium_info_url', ] as const) satisfies ReadonlyArray; +const SETUP_FLAG_KEYS = Object.freeze([ + 'configured', + 'account_identity_locked', + 'setup_token_required', +] as const) satisfies ReadonlyArray; + +const LEGAL_URL_KEYS = Object.freeze(['terms_url', 'privacy_url', 'guidelines_url'] as const) satisfies ReadonlyArray< + keyof InstanceLegal +>; + interface InstanceDiscoveryEndpoints { readonly api: string; readonly gateway: string; @@ -744,9 +754,11 @@ function parseSetup(value: unknown): Readonly> { return {}; } const setup: Partial = {}; - const configured = readBoolean(source, 'configured'); - if (configured != null) { - setup.configured = configured; + for (const key of SETUP_FLAG_KEYS) { + const flag = readBoolean(source, key); + if (flag != null) { + setup[key] = flag; + } } if (Object.hasOwn(source, 'admin_url')) { setup.admin_url = readAbsoluteHTTPURL(source, 'admin_url'); @@ -760,11 +772,10 @@ function parseLegal(value: unknown): Readonly> { return {}; } const legal: Partial = {}; - if (Object.hasOwn(source, 'terms_url')) { - legal.terms_url = readAbsoluteHTTPURL(source, 'terms_url'); - } - if (Object.hasOwn(source, 'privacy_url')) { - legal.privacy_url = readAbsoluteHTTPURL(source, 'privacy_url'); + for (const key of LEGAL_URL_KEYS) { + if (Object.hasOwn(source, key)) { + legal[key] = readAbsoluteHTTPURL(source, key); + } } return legal; }