The form appeared only when nothing was configured, so once a token was saved there was no way back to it — a member who mistyped theirs, or anyone rotating one, had nowhere to go. On a single-user machine that is an annoyance; on a company one it is the ordinary case, because every member has a token of their own to set. Uses the route pair the platform already generates for every plugin, so this needed no platform change. A plug icon beside the open-in-new-tab button, as a real Link rather than a button, because it is navigation and should be cmd-clickable. Saving returns to the frame — otherwise you are left looking at the form you just submitted, which reads as nothing having happened. The two halves were already right: the panel shows only the token field when isOwner is false, and getResolvedServiceCredentials gives a member the owner's URL with their OWN secret, never the owner's.
136 lines
5.9 KiB
TypeScript
136 lines
5.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link, useParams } from 'react-router';
|
|
import { ExternalLink, Loader2, Plug, TriangleAlert } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
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. */}
|
|
<div className="absolute right-3 top-3 z-10 flex gap-1.5 opacity-60 transition-opacity hover:opacity-100">
|
|
{/* A real link, not a button: it is navigation, and it should be cmd-clickable like any other. */}
|
|
<Button size="sm" variant="outline" asChild title="Connection settings">
|
|
<Link to="/gitea/connection">
|
|
<Plug className="h-3.5 w-3.5" />
|
|
</Link>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => window.open(FRAME_SRC, '_blank', 'noopener')}
|
|
title="Open Gitea in a new tab"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
<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>
|
|
);
|
|
};
|