fix(app): keep the new admin when setup meets one 401 (#2609)

This commit is contained in:
Hampus
2026-09-08 22:18:02 +02:00
committed by Hampus Kraft
parent c577b97f35
commit cf83f66911
3 changed files with 62 additions and 11 deletions
@@ -3,6 +3,7 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
import {
classifySetupUnauthorized,
fetchInstanceConfig,
type SetupBrandingAssetKind,
testSmtpConfig,
@@ -55,6 +56,7 @@ import {fileToBase64} from '@app/features/user/utils/AvatarUtils';
import * as FormUtils from '@app/lib/forms';
import {type ThemeType, ThemeTypes} from '@fluxer/constants/src/UserConstants';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowLeftIcon, ArrowRightIcon, CheckIcon, WrenchIcon} from '@phosphor-icons/react';
@@ -92,6 +94,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 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.',
comment: 'Error shown when the setup wizard cannot load because the API origin differs from the page origin.',
});
const ASSET_UPLOAD_ERROR_DESCRIPTOR = msg({
message: 'That image could not be used. Try a different file.',
comment: 'Error shown when a branding image fails to upload in the setup wizard.',
@@ -425,7 +432,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
const stepNavigationUnlockTimerRef = useRef<number | null>(null);
const [config, setConfig] = useState<InstanceConfigResponse | null>(null);
const [loadError, setLoadError] = useState(false);
const [loadError, setLoadError] = useState<MessageDescriptor | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [stepNavigationLocked, setStepNavigationLocked] = useState(false);
@@ -531,11 +538,11 @@ export const SelfHostedSetupWizardGate = observer(() => {
}, [authStoreAuthenticated, forceUnauthenticatedSetup]);
const resetStaleSetupSession = useCallback(async () => {
logger.warn('Instance config fetch returned 401 during setup; clearing stale local setup session');
logger.warn('The setup session token was rejected. Clearing the stale local setup session.');
setForceUnauthenticatedSetup(true);
registerFormDraftsRef.current.clear();
setConfig(null);
setLoadError(false);
setLoadError(null);
setSubmitError(null);
setSubmitting(false);
setWizardSnapshot(createSetupWizardSnapshot());
@@ -605,7 +612,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
useEffect(() => {
if (!isAuthenticated || config) return;
let cancelled = false;
setLoadError(false);
setLoadError(null);
void (async () => {
try {
const next = await fetchInstanceConfig();
@@ -613,12 +620,15 @@ export const SelfHostedSetupWizardGate = observer(() => {
hydrateFromConfig(next);
} catch (error) {
if (cancelled) return;
if (error instanceof HttpError && error.status === 401) {
const cause =
error instanceof HttpError && error.status === 401 ? await classifySetupUnauthorized() : 'unknown';
if (cancelled) return;
if (cause === 'stale_session') {
await resetStaleSetupSession();
return;
}
logger.error('Failed to load instance configuration', error);
setLoadError(true);
setLoadError(cause === 'origin_mismatch' ? ORIGIN_MISMATCH_DESCRIPTOR : LOAD_ERROR_DESCRIPTOR);
}
})();
return () => {
@@ -868,7 +878,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
role="alert"
data-flx="app.self-hosted-setup-wizard-gate.load-error"
>
{i18n._(LOAD_ERROR_DESCRIPTOR)}
{i18n._(loadError)}
</p>
</div>
) : (
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import SessionManager from '@app/features/platform/state/AuthSession';
import {http} from '@app/features/platform/transport/RestTransport';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {
BrandingAssetUploadRequest,
InstanceConfigResponse,
@@ -10,6 +12,8 @@ import type {
InstanceEmailSmtpTestResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
const logger = new Logger('SetupWizardClient');
export type SetupBrandingAssetKind = BrandingAssetUploadRequest['kind'];
export async function fetchInstanceConfig(): Promise<InstanceConfigResponse> {
@@ -35,3 +39,17 @@ export async function testSmtpConfig(body: InstanceEmailSmtpTestRequest): Promis
const response = await http.post<InstanceEmailSmtpTestResponse>(Endpoints.ADMIN_INSTANCE_CONFIG_SMTP_TESTS, {body});
return response.body;
}
export type SetupUnauthorizedCause = 'stale_session' | 'origin_mismatch' | 'unknown';
export async function classifySetupUnauthorized(): Promise<SetupUnauthorizedCause> {
if (!SessionManager.token) return 'unknown';
if (!http.carriesAuthorization()) return 'origin_mismatch';
try {
const response = await http.get(Endpoints.USER_ME, {mode: 'silent'});
return response.status === 401 ? 'stale_session' : 'unknown';
} catch (error) {
logger.warn('Could not confirm whether the setup session is still valid', error);
return 'unknown';
}
}
@@ -84,6 +84,8 @@ interface OnlineWaiter {
onAbort: () => void;
}
const strippedAuthorizationOrigins = new Set<string>();
const onlineWaiters = new Set<OnlineWaiter>();
let onlineListenerActive = false;
@@ -167,6 +169,10 @@ export class RestClient {
this.state.globalIntercept = hooks.intercept;
}
carriesAuthorization(): boolean {
return !isOffOrigin(resolveUrl(this.state, '/', undefined));
}
dispatch<T = unknown>(method: HttpMethod, path: string, options: RestRequestOptions = {}): Promise<RestResponse<T>> {
return runWithSudoEscalation<T>(this.state, method, path, options, 'fresh');
}
@@ -319,7 +325,12 @@ function composePlan(
): Plan {
const url = resolveUrl(state, path, options.query);
const body = encodeBody(options);
const sameOrigin = !looksAbsolute(path) && !isOffOrigin(url);
const targetsApiBase = !looksAbsolute(path);
const apiOrigin = targetsApiBase ? originOf(url) : null;
const sameOrigin = targetsApiBase && (apiOrigin === null || apiOrigin === window.location.origin);
if (apiOrigin !== null && !sameOrigin) {
reportStrippedAuthorization(state, apiOrigin, options.auth);
}
const headers = assembleHeaders({
state,
callerHeaders: options.headers,
@@ -365,14 +376,26 @@ function looksAbsolute(path: string): boolean {
return path.startsWith('//') || /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
}
function isOffOrigin(url: string): boolean {
function originOf(url: string): string | null {
try {
return new URL(url).origin !== window.location.origin;
return new URL(url).origin;
} catch {
return false;
return null;
}
}
function isOffOrigin(url: string): boolean {
const origin = originOf(url);
return origin !== null && origin !== window.location.origin;
}
function reportStrippedAuthorization(state: RuntimeState, apiOrigin: string, auth: RestAuthMode | undefined): void {
if (auth === 'none' || strippedAuthorizationOrigins.has(apiOrigin)) return;
if (!state.authProvider()) return;
strippedAuthorizationOrigins.add(apiOrigin);
log.warn(`authorization withheld from off-origin api base: ${apiOrigin} (page ${window.location.origin})`);
}
function encodeBody(options: RestRequestOptions): BodyShape {
if (options.multipart) {
return {tag: 'form', payload: buildFormData(options.multipart)};