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
+143 -41

No files matched your search

@@ -776,29 +776,11 @@ export const SelfHostedSetupWizardGate = observer(() => {
setSetupTokenError(null); 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); setSetupTokenError(message);
try { setWizardSnapshot((current) => transitionSetupWizardSnapshot(current, {type: 'wizard.setupTokenRejected'}));
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'},
),
);
}, []); }, []);
const submitSignInMethod = useCallback(async () => { const submitSignInMethod = useCallback(async () => {
@@ -816,7 +798,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
(violation) => violation.path === 'setup_token', (violation) => violation.path === 'setup_token',
); );
if (setupTokenViolation) { if (setupTokenViolation) {
await handleSetupTokenRejected(setupTokenViolation.message); handleSetupTokenRejected(setupTokenViolation.message);
setSavingSignInMethod(false); setSavingSignInMethod(false);
return; 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( function phaseSteps(
phase: WizardPhase, phase: WizardPhase,
currentStep: WizardStep,
accountIdentity: AccountIdentityMode, accountIdentity: AccountIdentityMode,
identityLocked: boolean, identityLocked: boolean,
setupTokenRequired: boolean, setupTokenRequired: boolean,
@@ -73,7 +74,9 @@ function phaseSteps(
switch (phase) { switch (phase) {
case 'register': case 'register':
return REGISTER_STEPS.filter( 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': case 'loading':
return LOADING_STEPS; return LOADING_STEPS;
@@ -90,12 +93,13 @@ interface SetupWizardMachineContext {
accountIdentity: AccountIdentityMode; accountIdentity: AccountIdentityMode;
identityLocked: boolean; identityLocked: boolean;
setupTokenRequired: boolean; setupTokenRequired: boolean;
setupTokenRejected: boolean;
} }
export type SetupWizardMachineEvent = export type SetupWizardMachineEvent =
| {type: 'wizard.next'} | {type: 'wizard.next'}
| {type: 'wizard.back'} | {type: 'wizard.back'}
| {type: 'wizard.goto'; step: WizardStep} | {type: 'wizard.setupTokenRejected'}
| { | {
type: 'wizard.sync'; type: 'wizard.sync';
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -116,9 +120,10 @@ export interface SetupWizardModel {
function contextSteps(context: SetupWizardMachineContext): ReadonlyArray<WizardStep> { function contextSteps(context: SetupWizardMachineContext): ReadonlyArray<WizardStep> {
return phaseSteps( return phaseSteps(
derivePhase(context.isAuthenticated, context.hasConfig), derivePhase(context.isAuthenticated, context.hasConfig),
context.step,
context.accountIdentity, context.accountIdentity,
context.identityLocked, 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)}; 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 { function targetsPhase(event: SetupWizardMachineEvent, context: SetupWizardMachineContext, phase: WizardPhase): boolean {
if (event.type !== 'wizard.sync') return false; if (event.type !== 'wizard.sync') return false;
return ( return (
@@ -169,7 +167,7 @@ const setupWizardStateMachine = setup({
}), }),
goNext: assign(({context}) => advance(context, 1)), goNext: assign(({context}) => advance(context, 1)),
goBack: 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}), enterRegister: assign({step: () => REGISTER_STEPS[0], direction: () => 0}),
enterLoading: assign({step: () => LOADING_STEPS[0], direction: () => 0}), enterLoading: assign({step: () => LOADING_STEPS[0], direction: () => 0}),
enterConfigure: assign({step: () => CONFIGURE_STEPS[0], direction: () => 0}), enterConfigure: assign({step: () => CONFIGURE_STEPS[0], direction: () => 0}),
@@ -184,6 +182,7 @@ const setupWizardStateMachine = setup({
accountIdentity: AccountIdentityModes.EMAIL, accountIdentity: AccountIdentityModes.EMAIL,
identityLocked: false, identityLocked: false,
setupTokenRequired: false, setupTokenRequired: false,
setupTokenRejected: false,
}, },
initial: 'register', initial: 'register',
on: { on: {
@@ -200,7 +199,7 @@ const setupWizardStateMachine = setup({
on: { on: {
'wizard.next': {actions: 'goNext'}, 'wizard.next': {actions: 'goNext'},
'wizard.back': {actions: 'goBack'}, 'wizard.back': {actions: 'goBack'},
'wizard.goto': {actions: 'goTo'}, 'wizard.setupTokenRejected': {actions: 'requireSetupToken'},
}, },
}, },
loading: { loading: {
@@ -23,6 +23,7 @@ import {
import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types'; import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types';
import {computeWireFormat} from '@fluxer/limits/src/LimitDiffer'; import {computeWireFormat} from '@fluxer/limits/src/LimitDiffer';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes'; import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {InstanceAppPublicSchema} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {runInAction} from 'mobx'; import {runInAction} from 'mobx';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; 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'); 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', () => { test('rejects a branding URL that is not an absolute HTTP URL', () => {
const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('e.test')); const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('e.test'));
expect(() => expect(() =>
@@ -399,6 +399,11 @@ function readAppPublic(value: unknown): InstanceAppPublic {
const setup = readRecord(source.setup, 'appPublic.setup'); const setup = readRecord(source.setup, 'appPublic.setup');
const legal = readRecord(source.legal, 'appPublic.legal'); const legal = readRecord(source.legal, 'appPublic.legal');
const registration = readRecord(source.registration, 'appPublic.registration'); 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'); const setupTokenRequired = readOptionalBoolean(setup, 'setup_token_required', 'appPublic.setup.setup_token_required');
return { return {
branding: { branding: {
@@ -419,6 +424,7 @@ function readAppPublic(value: unknown): InstanceAppPublic {
setup: { setup: {
configured: readBoolean(setup, 'configured', 'appPublic.setup.configured'), configured: readBoolean(setup, 'configured', 'appPublic.setup.configured'),
admin_url: readNullableAbsoluteHttpUrl(setup, 'admin_url', 'appPublic.setup.admin_url'), admin_url: readNullableAbsoluteHttpUrl(setup, 'admin_url', 'appPublic.setup.admin_url'),
...(accountIdentityLocked === undefined ? {} : {account_identity_locked: accountIdentityLocked}),
...(setupTokenRequired === undefined ? {} : {setup_token_required: setupTokenRequired}), ...(setupTokenRequired === undefined ? {} : {setup_token_required: setupTokenRequired}),
}, },
legal: { legal: {
@@ -133,6 +133,7 @@ function projectAppPublic(appPublic: InstanceDiscoveryDocument['appPublic']): In
premium_info_url: value.branding.premium_info_url ?? null, premium_info_url: value.branding.premium_info_url ?? null,
}, },
setup: { setup: {
...value.setup,
configured: requireDefined(value.setup.configured, 'app_public.setup.configured'), configured: requireDefined(value.setup.configured, 'app_public.setup.configured'),
admin_url: requireDefined(value.setup.admin_url, 'app_public.setup.admin_url'), admin_url: requireDefined(value.setup.admin_url, 'app_public.setup.admin_url'),
}, },
+18 -7
View File
@@ -75,6 +75,16 @@ const BRANDING_URL_KEYS = Object.freeze([
'premium_info_url', 'premium_info_url',
] as const) satisfies ReadonlyArray<keyof InstanceBranding>; ] as const) satisfies ReadonlyArray<keyof InstanceBranding>;
const SETUP_FLAG_KEYS = Object.freeze([
'configured',
'account_identity_locked',
'setup_token_required',
] as const) satisfies ReadonlyArray<keyof InstanceSetup>;
const LEGAL_URL_KEYS = Object.freeze(['terms_url', 'privacy_url', 'guidelines_url'] as const) satisfies ReadonlyArray<
keyof InstanceLegal
>;
interface InstanceDiscoveryEndpoints { interface InstanceDiscoveryEndpoints {
readonly api: string; readonly api: string;
readonly gateway: string; readonly gateway: string;
@@ -744,9 +754,11 @@ function parseSetup(value: unknown): Readonly<Partial<InstanceSetup>> {
return {}; return {};
} }
const setup: Partial<InstanceSetup> = {}; const setup: Partial<InstanceSetup> = {};
const configured = readBoolean(source, 'configured'); for (const key of SETUP_FLAG_KEYS) {
if (configured != null) { const flag = readBoolean(source, key);
setup.configured = configured; if (flag != null) {
setup[key] = flag;
}
} }
if (Object.hasOwn(source, 'admin_url')) { if (Object.hasOwn(source, 'admin_url')) {
setup.admin_url = readAbsoluteHTTPURL(source, 'admin_url'); setup.admin_url = readAbsoluteHTTPURL(source, 'admin_url');
@@ -760,11 +772,10 @@ function parseLegal(value: unknown): Readonly<Partial<InstanceLegal>> {
return {}; return {};
} }
const legal: Partial<InstanceLegal> = {}; const legal: Partial<InstanceLegal> = {};
if (Object.hasOwn(source, 'terms_url')) { for (const key of LEGAL_URL_KEYS) {
legal.terms_url = readAbsoluteHTTPURL(source, 'terms_url'); if (Object.hasOwn(source, key)) {
legal[key] = readAbsoluteHTTPURL(source, key);
} }
if (Object.hasOwn(source, 'privacy_url')) {
legal.privacy_url = readAbsoluteHTTPURL(source, 'privacy_url');
} }
return legal; return legal;
} }