mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app): keep the new admin when setup meets one 401 (#2609)
This commit is contained in:
@@ -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)};
|
||||
|
||||
Reference in New Issue
Block a user