Files
gitea/web/GiteaFrame.tsx
T
officer e820831aec do not render the frame before its session exists
Saving the connection showed an error that a refresh then cleared. The effect
that re-mints the frame session re-ran, but `state` was left at 'ready' from
the previous pass — so the iframe mounted immediately, before the cookie
existed, and the first request inside it answered 401. By the time anyone
refreshed, the mint had finished.

A stale 'ready' is worse than a spinner: it renders something broken
confidently.

Also stays on the form after the URL-only step, where there is still a token to
set. Bouncing to a frame that immediately sends you back is worse than not
moving.
2026-08-19 10:45:05 +00:00

128 lines
5.7 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { Loader2, TriangleAlert } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { GiteaConnection, type ConnectionState } from './GiteaConnection';
// Gitea's own web interface, rendered in place.
//
// ── Why an iframe and not panels ──
//
// Gitea has a complete web interface. The version of this that shipped before was seventeen files
// reimplementing a fraction of it, and every one of those files was a promise to keep up with a project
// that moves faster than this plugin will.
//
// ── The one thing this component must do ──
//
// Get a cookie before the frame loads. Everything inside the iframe — stylesheets, scripts, avatars — is
// fetched by the browser with no Authorization header, so the frame's URL is served above the platform's
// gate and authenticates with a path-scoped cookie instead. That cookie is minted by a normal
// authenticated call, which is what this does first.
//
// Load the iframe before that call returns and every request in it is unauthenticated, which reads as
// Gitea being down.
const FRAME_SRC = '/api/gitea/_web/';
export const GiteaFrame = () => {
// `/gitea/connection` renders the settings instead of the frame. The plugin route pair gives this for
// free — `<route>` and `<route>/:section` are generated for every plugin — and it is the only way to
// change a token once one is set: the form used to appear ONLY when nothing was configured, so a
// member who mistyped theirs, or anyone rotating one, had nowhere to go.
const { section } = useParams();
const { get, post } = useClient();
const [state, setState] = useState<'opening' | 'ready' | 'failed'>('opening');
const [connection, setConnection] = useState<ConnectionState | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
// BACK TO 'opening' on every re-run, not just the first.
//
// Without this, saving the connection left `state` at 'ready' from the previous pass, so the iframe
// mounted immediately — before the session cookie for it had been minted. The first request inside
// the frame answered 401 and showed an error that a refresh then cleared, because by then the mint
// had finished.
//
// A stale 'ready' is worse than a spinner: it renders something broken confidently.
setState('opening');
// Not in a dependency array: `useClient()` returns a new object every render, so depending on `get`
// or `post` would re-run this on every keystroke elsewhere in the app.
void (async () => {
try {
// The connection FIRST. Without it the frame loads and every request inside it answers 503,
// which reads as Gitea being down rather than as nobody having said where Gitea is.
const conn = await get<ConnectionState>('/gitea/_config');
if (cancelled) return;
setConnection(conn);
if (!conn.configured) {
setState('ready');
return;
}
await post('/gitea/session', {});
if (!cancelled) setState('ready');
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'could not open a session');
setState('failed');
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attempt]);
if (state === 'opening') {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm">Opening Gitea…</span>
</div>
);
}
if (state === 'failed') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<TriangleAlert className="h-6 w-6 text-warning" />
<div>
<p className="text-sm font-medium">Could not open Gitea</p>
{/* The message, not a generic one: the interesting failure here is almost always "the sidecar
is not running" or "no instance is configured", and those want different answers. */}
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
</div>
</div>
);
}
// Unconfigured goes straight to the form; `/gitea/connection` opens it deliberately.
if (connection && (!connection.configured || section === 'connection')) {
return <GiteaConnection state={connection} onSaved={() => setAttempt((n) => n + 1)} />;
}
return (
<div className="relative h-full w-full">
{/* The escape hatch, and it is not an admission of defeat: the whole point of proxying an app that
already has a UI is that its own UI stays available. Anything the frame handles badly, this
opens properly. */}
<iframe
src={FRAME_SRC}
title="Gitea"
className="h-full w-full border-0"
// NO `sandbox`, deliberately.
//
// It needs `allow-same-origin` for Gitea to keep a session and `allow-scripts` for the interface
// to work at all — and those two together give back everything the attribute takes away. The
// browser says so itself: "an iframe which has both allow-scripts and allow-same-origin can
// escape its sandboxing."
//
// So it was decoration that emitted a warning on every load, and a warning nobody can act on
// teaches people to ignore warnings. What actually stands here is that this is Gitea's own
// interface on the platform's own origin, reached through a proxy the platform wrote.
/>
</div>
);
};