They were floating buttons over the top-right of the iframe, which was wrong twice: they sit on top of Gitea's own controls, and an overlay on an application that fills its whole panel is invisible until you already know it is there. Reported as "I have no access to settings when the iframe is loaded", which is exactly right. The header is the platform's own furniture — always present, never competing with the content underneath — and it is where photos and jellyfin put theirs. It also names where you are, so /gitea/connection reads as "Gitea — connection" rather than looking like the frame failed to load.
119 lines
5.2 KiB
TypeScript
119 lines
5.2 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;
|
|
// 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>
|
|
);
|
|
};
|