fix(setup): show the setup token step in the wizard (#3442)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 03:22:42 +02:00
1 parent 922ff5ea64
commit 3d4b2933e1
7 files changed
+144 -42

No files matched your search

@@ -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;
}
@@ -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');
});
});
@@ -66,6 +66,7 @@ const USERNAME_CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
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<WizardStep> {
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<Set
return {step: steps[nextIndex], direction: Math.sign(delta)};
}
function jump(context: SetupWizardMachineContext, step: WizardStep): Partial<SetupWizardMachineContext> {
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: {
@@ -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(() =>
@@ -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: {