feat(setup): add a setup token for first-run setup (#3437)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-13 00:17:24 +02:00
1 parent 8e39380ee6
commit 78272521bb
135 files changed
+3045 -771

No files matched your search

@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import * as Modal from '@app/features/app/components/dialogs/Modal';
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
import {
@@ -39,6 +40,7 @@ import {
type ServiceIntegrationDraft,
type ServiceSelection,
ServicesStep,
SetupTokenStep,
SignInMethodStep,
ThemeStep,
toSmtpTlsMode,
@@ -48,15 +50,16 @@ import {resolveAppShellBranding} from '@app/features/app/state/AppShellBranding'
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Authentication from '@app/features/auth/state/Authentication';
import {AuthRegisterDraftContext, type AuthRegisterFormDraft} from '@app/features/auth/state/AuthRegisterDraftContext';
import {BACK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {BACK_DESCRIPTOR, SIGN_OUT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork';
import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort';
import SessionManager from '@app/features/platform/state/AuthSession';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {readBlobAsDataUrl as fileToBase64} from '@app/features/platform/utils/BlobDataUrl';
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
import {failureCode, failureValidationErrors} from '@app/features/platform/utils/ResponseInspection';
import Theme from '@app/features/theme/state/Theme';
import {Button} from '@app/features/ui/button/Button';
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
@@ -132,6 +135,11 @@ const LOAD_ERROR_DESCRIPTOR = msg({
message: 'Could not load the instance configuration. Try reloading the page.',
comment: 'Error shown when the setup wizard fails to load the instance configuration.',
});
const NOT_ADMIN_DESCRIPTOR = msg({
message: 'This account is not an admin of this instance. Sign in with an admin account to finish setup.',
comment:
'Error shown in the setup wizard when the signed-in account lacks admin access to the unconfigured instance. A sign out button follows.',
});
const ORIGIN_MISMATCH_DESCRIPTOR = msg({
message:
'The API is on a different origin than this page, so setup requests are sent without your session. Check the public origin and port this instance is configured with, then reload.',
@@ -460,6 +468,8 @@ export const SelfHostedSetupWizardGate = observer(() => {
const [tagStyleChoice, setTagStyleChoice] = useState<TagStyle>(() => RuntimeConfig.tagStyle);
const [savingSignInMethod, setSavingSignInMethod] = useState(false);
const [signInMethodError, setSignInMethodError] = useState<string | null>(null);
const [setupToken, setSetupToken] = useState('');
const [setupTokenError, setSetupTokenError] = useState<string | null>(null);
const [creatingRecoveryKit, setCreatingRecoveryKit] = useState(false);
const [productName, setProductName] = useState('');
@@ -552,8 +562,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
setForceUnauthenticatedSetup(false);
}, [authStoreAuthenticated, forceUnauthenticatedSetup]);
const resetStaleSetupSession = useCallback(async () => {
logger.warn('The setup session token was rejected. Clearing the stale local setup session.');
const resetSetupSession = useCallback(async () => {
setForceUnauthenticatedSetup(true);
registerFormDraftsRef.current.clear();
setConfig(null);
@@ -569,17 +578,24 @@ export const SelfHostedSetupWizardGate = observer(() => {
setSmtpTestResult(null);
setSignInMethodError(null);
setSavingSignInMethod(false);
setSetupToken('');
setSetupTokenError(null);
setCreatingRecoveryKit(false);
try {
await SessionManager.logout();
} catch (error) {
logger.warn('Failed to fully clear stale setup session', error);
logger.warn('Failed to fully clear the setup session', error);
Authentication.handleSessionStart({token: null});
SessionManager.setUserId(null);
}
Authentication.handleLogout({skipRedirect: true});
}, [clearStepNavigationLock]);
const signOutOfSetup = useCallback(async () => {
await resetSetupSession();
if (RuntimeConfig.appPublic.setup.setup_token_required !== true) RouterUtils.replaceWith(Routes.LOGIN);
}, [resetSetupSession]);
const hydrateFromConfig = useCallback((next: InstanceConfigResponse) => {
setConfig(next);
setProductName(next.app_public.branding.product_name);
@@ -643,7 +659,12 @@ export const SelfHostedSetupWizardGate = observer(() => {
error instanceof HttpError && error.status === 401 ? await classifySetupUnauthorized() : 'unknown';
if (cancelled) return;
if (cause === 'stale_session') {
await resetStaleSetupSession();
logger.warn('The setup session token was rejected. Clearing the stale local setup session.');
await resetSetupSession();
return;
}
if (error instanceof HttpError && error.status === 403) {
setLoadError(NOT_ADMIN_DESCRIPTOR);
return;
}
logger.error('Failed to load instance configuration', error);
@@ -653,7 +674,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
return () => {
cancelled = true;
};
}, [isAuthenticated, config, accountWorkAdmitted, hydrateFromConfig, resetStaleSetupSession]);
}, [isAuthenticated, config, accountWorkAdmitted, hydrateFromConfig, resetSetupSession]);
const serviceAvailability: ServiceAvailability = useMemo(
() => ({
@@ -675,17 +696,20 @@ export const SelfHostedSetupWizardGate = observer(() => {
const hasConfig = Boolean(config);
const identityLocked = RuntimeConfig.appPublic.setup.account_identity_locked === true;
const setupTokenRequired = RuntimeConfig.appPublic.setup.setup_token_required === true;
const syncedSnapshot =
wizardSnapshot.context.isAuthenticated !== isAuthenticated ||
wizardSnapshot.context.hasConfig !== hasConfig ||
wizardSnapshot.context.accountIdentity !== accountIdentity ||
wizardSnapshot.context.identityLocked !== identityLocked
wizardSnapshot.context.identityLocked !== identityLocked ||
wizardSnapshot.context.setupTokenRequired !== setupTokenRequired
? transitionSetupWizardSnapshot(wizardSnapshot, {
type: 'wizard.sync',
isAuthenticated,
hasConfig,
accountIdentity,
identityLocked,
setupTokenRequired,
})
: wizardSnapshot;
if (syncedSnapshot !== wizardSnapshot) {
@@ -712,6 +736,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
const canAdvance = useMemo(() => {
if (step === 'welcome') return !isAuthenticated || Boolean(config);
if (step === 'admin_account' || step === 'loading') return false;
if (step === 'setup_token') return setupToken.trim().length > 0;
if (step === 'sign_in_method') return !savingSignInMethod;
if (step === 'admin_recovery_kit') return hasRecoveryKit;
if (step === 'branding') return !productNameError;
@@ -731,6 +756,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
integrationDraft,
savingSignInMethod,
hasRecoveryKit,
setupToken,
]);
const goNext = useCallback(() => {
@@ -745,6 +771,36 @@ export const SelfHostedSetupWizardGate = observer(() => {
setWizardSnapshot((current) => transitionSetupWizardSnapshot(current, {type: 'wizard.back'}));
}, [beginStepNavigation]);
const handleSetupTokenChange = useCallback((value: string) => {
setSetupToken(value);
setSetupTokenError(null);
}, []);
const submittedSetupToken = setupTokenRequired ? setupToken.trim() : undefined;
const handleSetupTokenRejected = useCallback(async (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'},
),
);
}, []);
const submitSignInMethod = useCallback(async () => {
if (savingSignInMethod) return;
setSavingSignInMethod(true);
@@ -754,8 +810,16 @@ export const SelfHostedSetupWizardGate = observer(() => {
try {
let saved: Awaited<ReturnType<typeof setSetupAccountIdentity>> | null = null;
try {
saved = await setSetupAccountIdentity(signInMethod, effectiveTagStyle);
saved = await setSetupAccountIdentity(signInMethod, effectiveTagStyle, submittedSetupToken);
} catch (error) {
const setupTokenViolation = failureValidationErrors(error)?.find(
(violation) => violation.path === 'setup_token',
);
if (setupTokenViolation) {
await handleSetupTokenRejected(setupTokenViolation.message);
setSavingSignInMethod(false);
return;
}
if (failureCode(error) !== APIErrorCodes.ACCOUNT_IDENTITY_LOCKED) {
throw error;
}
@@ -792,7 +856,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
}
setSavingSignInMethod(false);
goNext();
}, [savingSignInMethod, signInMethod, tagStyleChoice, i18n, goNext]);
}, [savingSignInMethod, signInMethod, tagStyleChoice, submittedSetupToken, handleSetupTokenRejected, i18n, goNext]);
const handleSignInMethodChange = useCallback((mode: AccountIdentityMode) => {
setSignInMethod(mode);
@@ -1022,6 +1086,15 @@ export const SelfHostedSetupWizardGate = observer(() => {
>
{i18n._(loadError)}
</p>
{loadError === NOT_ADMIN_DESCRIPTOR && (
<Button
variant="secondary"
onClick={signOutOfSetup}
data-flx="app.self-hosted-setup-wizard-gate.load-error-sign-out"
>
{i18n._(SIGN_OUT_DESCRIPTOR)}
</Button>
)}
</div>
) : (
<SteppedCarousel
@@ -1040,6 +1113,14 @@ export const SelfHostedSetupWizardGate = observer(() => {
data-flx="app.setup.self-hosted-setup-wizard-gate.welcome-step"
/>
)}
{step === 'setup_token' && (
<SetupTokenStep
value={setupToken}
onChange={handleSetupTokenChange}
error={setupTokenError}
data-flx="app.setup.self-hosted-setup-wizard-gate.setup-token-step"
/>
)}
{step === 'theme' && (
<ThemeStep
theme={setupTheme}
@@ -1068,6 +1149,8 @@ export const SelfHostedSetupWizardGate = observer(() => {
<AdminAccountStep
theme={setupTheme}
usernameSignIn={accountIdentity === AccountIdentityModes.USERNAME}
setupToken={submittedSetupToken}
onSetupTokenRejected={handleSetupTokenRejected}
data-flx="app.setup.self-hosted-setup-wizard-gate.admin-account-step"
/>
)}
@@ -42,8 +42,13 @@ export async function uploadBrandingAsset(
export async function setSetupAccountIdentity(
mode: InstanceAccountIdentityUpdateRequest['mode'],
tagStyle?: InstanceAccountIdentityUpdateRequest['tag_style'],
setupToken?: string,
): Promise<InstanceAccountIdentityResponse> {
const body: InstanceAccountIdentityUpdateRequest = tagStyle === undefined ? {mode} : {mode, tag_style: tagStyle};
const body: InstanceAccountIdentityUpdateRequest = {
mode,
...(tagStyle === undefined ? {} : {tag_style: tagStyle}),
...(setupToken ? {setup_token: setupToken} : {}),
};
const response = await http.put<InstanceAccountIdentityResponse>(Endpoints.INSTANCE_SETUP_ACCOUNT_IDENTITY, {body});
return response.body;
}
@@ -5,6 +5,7 @@ import {assign, initialTransition, type SnapshotFrom, setup, transition} from 'x
export type WizardStep =
| 'welcome'
| 'setup_token'
| 'theme'
| 'sign_in_method'
| 'admin_intro'
@@ -28,6 +29,7 @@ type WizardPhase = 'register' | 'loading' | 'configure';
const REGISTER_STEPS: ReadonlyArray<WizardStep> = [
'welcome',
'setup_token',
'theme',
'sign_in_method',
'admin_intro',
@@ -65,11 +67,14 @@ const USERNAME_CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
function phaseSteps(
phase: WizardPhase,
accountIdentity: AccountIdentityMode,
identityLocked = false,
identityLocked: boolean,
setupTokenRequired: boolean,
): ReadonlyArray<WizardStep> {
switch (phase) {
case 'register':
return identityLocked ? REGISTER_STEPS.filter((step) => step !== 'sign_in_method') : REGISTER_STEPS;
return REGISTER_STEPS.filter(
(step) => (step !== 'sign_in_method' || !identityLocked) && (step !== 'setup_token' || setupTokenRequired),
);
case 'loading':
return LOADING_STEPS;
case 'configure':
@@ -84,17 +89,20 @@ interface SetupWizardMachineContext {
hasConfig: boolean;
accountIdentity: AccountIdentityMode;
identityLocked: boolean;
setupTokenRequired: boolean;
}
export type SetupWizardMachineEvent =
| {type: 'wizard.next'}
| {type: 'wizard.back'}
| {type: 'wizard.goto'; step: WizardStep}
| {
type: 'wizard.sync';
isAuthenticated: boolean;
hasConfig: boolean;
accountIdentity: AccountIdentityMode;
identityLocked: boolean;
setupTokenRequired: boolean;
};
export interface SetupWizardModel {
@@ -105,18 +113,30 @@ export interface SetupWizardModel {
stepIndex: number;
}
function advance(context: SetupWizardMachineContext, delta: number): Partial<SetupWizardMachineContext> {
const steps = phaseSteps(
function contextSteps(context: SetupWizardMachineContext): ReadonlyArray<WizardStep> {
return phaseSteps(
derivePhase(context.isAuthenticated, context.hasConfig),
context.accountIdentity,
context.identityLocked,
context.setupTokenRequired,
);
}
function advance(context: SetupWizardMachineContext, delta: number): Partial<SetupWizardMachineContext> {
const steps = contextSteps(context);
const index = steps.indexOf(context.step);
const safeIndex = index < 0 ? 0 : index;
const nextIndex = Math.min(steps.length - 1, Math.max(0, safeIndex + 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 {
if (event.type !== 'wizard.sync') return false;
return (
@@ -144,9 +164,12 @@ const setupWizardStateMachine = setup({
event.type === 'wizard.sync' ? event.accountIdentity : context.accountIdentity,
identityLocked: ({context, event}) =>
event.type === 'wizard.sync' ? event.identityLocked : context.identityLocked,
setupTokenRequired: ({context, event}) =>
event.type === 'wizard.sync' ? event.setupTokenRequired : context.setupTokenRequired,
}),
goNext: assign(({context}) => advance(context, 1)),
goBack: assign(({context}) => advance(context, -1)),
goTo: assign(({context, event}) => (event.type === 'wizard.goto' ? jump(context, event.step) : {})),
enterRegister: assign({step: () => REGISTER_STEPS[0], direction: () => 0}),
enterLoading: assign({step: () => LOADING_STEPS[0], direction: () => 0}),
enterConfigure: assign({step: () => CONFIGURE_STEPS[0], direction: () => 0}),
@@ -160,6 +183,7 @@ const setupWizardStateMachine = setup({
hasConfig: false,
accountIdentity: AccountIdentityModes.EMAIL,
identityLocked: false,
setupTokenRequired: false,
},
initial: 'register',
on: {
@@ -176,6 +200,7 @@ const setupWizardStateMachine = setup({
on: {
'wizard.next': {actions: 'goNext'},
'wizard.back': {actions: 'goBack'},
'wizard.goto': {actions: 'goTo'},
},
},
loading: {
@@ -206,7 +231,7 @@ export function transitionSetupWizardSnapshot(
export function selectSetupWizardModel(snapshot: SetupWizardSnapshot): SetupWizardModel {
const phase = derivePhase(snapshot.context.isAuthenticated, snapshot.context.hasConfig);
const steps = phaseSteps(phase, snapshot.context.accountIdentity, snapshot.context.identityLocked);
const steps = contextSteps(snapshot.context);
const stepIndex = steps.indexOf(snapshot.context.step);
return {
phase,
@@ -56,6 +56,24 @@ const WELCOME_LANGUAGE_LABEL_DESCRIPTOR = msg({
message: 'Language',
comment: 'Label for the setup wizard language selector.',
});
const SETUP_TOKEN_TITLE_DESCRIPTOR = msg({
message: 'Enter your setup token',
comment: 'Setup wizard title for the step that asks the operator for the setup token of a new instance.',
});
const SETUP_TOKEN_BODY_DESCRIPTOR = msg({
message: 'The token proves that you run this instance. Only the person who has it can create the admin account.',
comment: 'Setup wizard body explaining why the operator has to enter the setup token.',
});
const SETUP_TOKEN_LABEL_DESCRIPTOR = msg({
message: 'Setup token',
comment: 'Label for the setup token input in the setup wizard.',
});
const SETUP_TOKEN_HELP_DESCRIPTOR = msg({
message:
'The API wrote the token to its log the first time it started. If you set FLUXER_SETUP_TOKEN, use that value.',
comment:
'Help text under the setup token input in the setup wizard. FLUXER_SETUP_TOKEN is an environment variable name and must not be translated.',
});
const THEME_TITLE_DESCRIPTOR = msg({
message: 'Choose a theme',
comment: 'Setup wizard title for choosing the initial account theme.',
@@ -558,6 +576,37 @@ export const WelcomeStep = observer(
},
);
export const SetupTokenStep = observer(
({value, onChange, error}: {value: string; onChange: (value: string) => void; error: string | null}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.setup-token-step">
<StepHeader
title={i18n._(SETUP_TOKEN_TITLE_DESCRIPTOR)}
body={i18n._(SETUP_TOKEN_BODY_DESCRIPTOR)}
data-flx="app.setup.setup-wizard-steps.setup-token-step.step-header"
/>
<Input
label={i18n._(SETUP_TOKEN_LABEL_DESCRIPTOR)}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete="off"
spellCheck={false}
maxLength={256}
error={error ?? undefined}
footer={
<p className={styles.body} data-flx="app.self-hosted-setup-wizard-gate.setup-token-help">
{i18n._(SETUP_TOKEN_HELP_DESCRIPTOR)}
</p>
}
data-flx="app.self-hosted-setup-wizard-gate.setup-token-input"
/>
</section>
);
},
);
export const ThemeStep = observer(
({theme, onThemeChange}: {theme: ThemeType; onThemeChange: (theme: ThemeType) => void}) => {
const {i18n} = useLingui();
@@ -685,31 +734,45 @@ export const AdminIntroStep = observer(() => {
);
});
export const AdminAccountStep = observer(({theme, usernameSignIn}: {theme: ThemeType; usernameSignIn: boolean}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
redirectPath=""
runtimeSnapshot={RuntimeConfig.getSnapshot()}
offerRecoveryKit={usernameSignIn}
theme={theme}
showLegalConsent={false}
data-flx="app.self-hosted-setup-wizard-gate.admin-register-form"
/>
</div>
</section>
);
});
export const AdminAccountStep = observer(
({
theme,
usernameSignIn,
setupToken,
onSetupTokenRejected,
}: {
theme: ThemeType;
usernameSignIn: boolean;
setupToken: string | undefined;
onSetupTokenRejected: (message: string) => void;
}) => {
const {i18n} = useLingui();
return (
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
redirectPath=""
runtimeSnapshot={RuntimeConfig.getSnapshot()}
offerRecoveryKit={usernameSignIn}
theme={theme}
showLegalConsent={false}
setupToken={setupToken}
onSetupTokenRejected={onSetupTokenRejected}
data-flx="app.self-hosted-setup-wizard-gate.admin-register-form"
/>
</div>
</section>
);
},
);
export const AdminRecoveryKitStep = observer(
({hasRecoveryKit, creating, onCreate}: {hasRecoveryKit: boolean; creating: boolean; onCreate: () => void}) => {
@@ -20,12 +20,12 @@ const TITLE_DESCRIPTOR = msg({
comment: 'Title of the modal that explains how to install the app from its new domain. productName is the app name.',
});
const INTRO_DESCRIPTOR = msg({
message: '{productName} now lives at {host}. Install it from there, then sign in with this app.',
message: '{productName} is now at {host}. Install it from there, then sign in with this app.',
comment:
'Intro in the modal that explains how to install the app from its new domain. productName is the app name. host is the new domain, for example fluxer.com.',
});
const INSTALL_INTRO_DESCRIPTOR = msg({
message: '{productName} now lives at {host}. Install it from there.',
message: '{productName} is now at {host}. Install it from there.',
comment:
'Intro in the modal that explains how to install the app from its new domain when the account already moved. productName is the app name. host is the new domain, for example fluxer.com.',
});
@@ -399,6 +399,7 @@ 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 setupTokenRequired = readOptionalBoolean(setup, 'setup_token_required', 'appPublic.setup.setup_token_required');
return {
branding: {
product_name: readNonEmptyString(branding, 'product_name', 'appPublic.branding.product_name'),
@@ -418,6 +419,7 @@ function readAppPublic(value: unknown): InstanceAppPublic {
setup: {
configured: readBoolean(setup, 'configured', 'appPublic.setup.configured'),
admin_url: readNullableAbsoluteHttpUrl(setup, 'admin_url', 'appPublic.setup.admin_url'),
...(setupTokenRequired === undefined ? {} : {setup_token_required: setupTokenRequired}),
},
legal: {
terms_url: readNullableAbsoluteHttpUrl(legal, 'terms_url', 'appPublic.legal.terms_url'),