The frame answered {"error":"internal error"} on every request. The cause was
AES-GCM failing with "Unsupported state or unable to authenticate data" — the
token in service_connections was encrypted under a key that no longer exists,
because the platform mints its encryption key on first use and does not back it
up. The same loss took slskd, memos and gitea credentials on this project's own
server earlier today.
It presented as a broken sidecar. connectionState reported `configured: true`
because a ROW exists, so the panel skipped the form and went to the frame,
where every call threw. Nothing offered a way to fix it, and the connection
form was unreachable precisely because it only appears when unconfigured.
Now a named failure. `UnreadableCredentials` is distinguished from every other
error — a database that is down keeps its own message, or a real fault would be
reported as "re-enter your token" — and the panel shows the form saying the key
changed, nothing else is lost, and the URL is still there.
Verified by the platform itself refusing to decrypt: this is the state that
machine has been in all day.
101 lines
4.2 KiB
TypeScript
101 lines
4.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
// Where the instance and the token are set.
|
|
//
|
|
// Two halves, and which one you get depends on who you are. The URL is the OWNER'S — one instance per
|
|
// server — and everyone else supplies only a token, inheriting the owner's URL. That is not a UI choice:
|
|
// `service_connections` stores a member's row with a null URL for exactly this reason, so that "members
|
|
// never see the instance URL" is a property of the schema rather than a filter somebody has to remember.
|
|
//
|
|
// A blank token on save means "keep the stored one", so the URL can be corrected without re-pasting a
|
|
// token. There is no way to read one back out.
|
|
|
|
export type ConnectionState = {
|
|
configured: boolean;
|
|
url?: string | null;
|
|
user?: string | null;
|
|
isOwner?: boolean;
|
|
/**
|
|
* A token is stored but cannot be decrypted.
|
|
*
|
|
* The platform's encryption key is minted on first use and not backed up, so losing it re-mints one
|
|
* and every previously encrypted column becomes unreadable. Reported separately from "not configured"
|
|
* because the person needs telling that nothing is broken and their URL is still there — only the
|
|
* token has to be pasted again.
|
|
*/
|
|
unreadable?: boolean;
|
|
};
|
|
|
|
type Props = { state: ConnectionState; onSaved: () => void };
|
|
|
|
export const GiteaConnection = ({ state, onSaved }: Props) => {
|
|
const { put } = useClient();
|
|
// Loopback is the right default and worth putting in front of someone: the sidecar fetches
|
|
// SERVER-SIDE, so `localhost` means the machine Officer runs on. It needs no TLS and never leaves the
|
|
// box — where the public URL would take the same traffic out and back.
|
|
const [url, setUrl] = useState(state.url ?? 'http://localhost:3000');
|
|
const [token, setToken] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await put('/gitea/_config', state.isOwner === false ? { token } : { url, token });
|
|
onSaved();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'could not save');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto flex h-full max-w-md flex-col justify-center gap-4 p-6">
|
|
<div>
|
|
<h2 className="text-base font-semibold">{state.unreadable ? 'Re-enter your token' : 'Connect Gitea'}</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{state.unreadable
|
|
? "The stored token can no longer be read — this server's encryption key changed. Nothing else is lost; paste it again."
|
|
: state.isOwner === false
|
|
? 'Your own access token. The instance is already set.'
|
|
: 'Where Gitea runs, and a token for it.'}
|
|
</p>
|
|
</div>
|
|
|
|
{state.isOwner !== false && (
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">Instance URL</span>
|
|
<Input value={url} onChange={(ev) => setUrl(ev.target.value)} placeholder="http://localhost:3000" />
|
|
<span className="block text-xs text-muted-foreground">
|
|
Officer reaches this from the server, not from your browser — so localhost means the machine
|
|
Officer runs on.
|
|
</span>
|
|
</label>
|
|
)}
|
|
|
|
<label className="space-y-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">Access token</span>
|
|
<Input
|
|
type="password"
|
|
value={token}
|
|
onChange={(ev) => setToken(ev.target.value)}
|
|
placeholder={state.configured && !state.unreadable ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'}
|
|
/>
|
|
</label>
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
<Button onClick={save} disabled={busy || ((!state.configured || state.unreadable) && !token)}>
|
|
{busy ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
|
{state.configured ? 'Save' : 'Connect'}
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|