mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(setup): show the setup token step in the wizard (#3442)
This commit is contained in:
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);
|
||||
}, []);
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -75,6 +75,16 @@ const BRANDING_URL_KEYS = Object.freeze([
|
||||
'premium_info_url',
|
||||
] 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 {
|
||||
readonly api: string;
|
||||
readonly gateway: string;
|
||||
@@ -744,9 +754,11 @@ function parseSetup(value: unknown): Readonly<Partial<InstanceSetup>> {
|
||||
return {};
|
||||
}
|
||||
const setup: Partial<InstanceSetup> = {};
|
||||
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<Partial<InstanceLegal>> {
|
||||
return {};
|
||||
}
|
||||
const legal: Partial<InstanceLegal> = {};
|
||||
if (Object.hasOwn(source, 'terms_url')) {
|
||||
legal.terms_url = readAbsoluteHTTPURL(source, 'terms_url');
|
||||
for (const key of LEGAL_URL_KEYS) {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user