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);
|
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'),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user