The owner installed it, landed on the configuration form, and could not get off it. Not a bug in the form: there was nothing behind it. One panel was registered and it was the settings. That is the wrong shape for a plugin regardless of how much is finished. A screen whose only content is its own configuration reads as broken rather than unfinished, because the first thing anybody does after installing is look for the way forward. So: collections on the left, links on the right, and the settings behind the gear in the collections panel - reachable when something is wrong and out of the way when it is not. Selection is `?collection=` in the URL rather than a channel, so which collection you are looking at survives a reload and can be linked to. Search is Linkwarden's own `searchQueryString`, which searches names, descriptions, tags AND the archived page text - the thing a bookmark bar cannot do, and the reason this is worth more than a list of addresses. Doing it here would mean pulling every link to the browser and would still only see the fields we happened to fetch. Verified against the instance: 50 unfiltered, 8 for "arch", 0 for a string matching nothing. The panel holds no credential and could not send one. Every request goes through the authenticated door, which finds the caller's mapped account and puts their token on. `no Linkwarden account is mapped to you` is the state every user starts in, so it is said as an instruction rather than rendered as an outage.
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
/**
|
|
* Where this install's Linkwarden is, and whether it answers.
|
|
*
|
|
* Was the whole plugin for an afternoon, which was the mistake: a settings screen with nothing behind it
|
|
* is a screen you cannot leave. It is a disclosure inside the collections panel now — reachable when
|
|
* something is wrong, and out of the way when it is not.
|
|
*
|
|
* ── Two addresses, and they are not the same one ──
|
|
*
|
|
* The field below is the address the SIDECAR dials, which on a machine running both Officer and
|
|
* Linkwarden is loopback. It is NOT the address a browser or the extension is given — that is
|
|
* `NEXTAUTH_URL` inside Linkwarden's own container, and it has to be reachable from wherever the client
|
|
* is. Measured on this install: the name Linkwarden advertises resolves to 127.0.1.1 on the host it runs
|
|
* on, where nothing is bound, so a sidecar told to use it dials a name that answers from everywhere
|
|
* except the one place that matters.
|
|
*/
|
|
type Config = { url: string | null; enabled: boolean; updatedAt: string | null };
|
|
type Health = { ok: boolean; upstream?: number; reason?: string };
|
|
|
|
export const SettingsForm = () => {
|
|
const client = useClient();
|
|
// `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list.
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [config, setConfig] = useState<Config | null>(null);
|
|
const [url, setUrl] = useState('');
|
|
const [adminToken, setAdminToken] = useState('');
|
|
const [health, setHealth] = useState<Health | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const next = await clientRef.current.get<Config>('/linkwarden/config');
|
|
setConfig(next);
|
|
setUrl(next.url ?? '');
|
|
setError(null);
|
|
} catch {
|
|
setError('Could not read the configuration.');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const check = useCallback(async () => {
|
|
setHealth(null);
|
|
try {
|
|
setHealth(await clientRef.current.get<Health>('/linkwarden/config/health'));
|
|
} catch {
|
|
setHealth({ ok: false, reason: 'the check itself failed' });
|
|
}
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setBusy(true);
|
|
try {
|
|
// The token is sent only when one was typed: leaving a write-only field alone means "unchanged",
|
|
// not "clear it".
|
|
await clientRef.current.put('/linkwarden/config', {
|
|
url: url.trim() || null,
|
|
...(adminToken.trim() ? { adminToken: adminToken.trim() } : {}),
|
|
});
|
|
setAdminToken('');
|
|
setError(null);
|
|
await load();
|
|
await check();
|
|
} catch (failure) {
|
|
setError(failure instanceof Error ? failure.message : 'Could not save that.');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3 border-t p-3">
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
<div className="space-y-1">
|
|
<label className="text-[11px] text-muted-foreground">Address the sidecar dials</label>
|
|
<Input
|
|
value={url}
|
|
onChange={(event) => setUrl(event.target.value)}
|
|
placeholder="http://127.0.0.1:3000"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="text-[11px] text-muted-foreground">Admin access token</label>
|
|
<Input
|
|
type="password"
|
|
value={adminToken}
|
|
onChange={(event) => setAdminToken(event.target.value)}
|
|
placeholder={config?.url ? 'unchanged' : 'Linkwarden → Settings → Access Tokens'}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" className="h-7 text-xs" onClick={() => void save()} disabled={busy}>
|
|
{busy ? 'Saving…' : 'Save'}
|
|
</Button>
|
|
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => void check()}>
|
|
Test
|
|
</Button>
|
|
</div>
|
|
|
|
{health && (
|
|
<p className="text-[11px]">
|
|
{health.ok ? (
|
|
<span className="text-muted-foreground">Answering{health.upstream ? ` (${health.upstream})` : ''}.</span>
|
|
) : (
|
|
/*
|
|
Three distinct reasons on purpose — unconfigured, unreachable, sidecar not running are three
|
|
different next actions, and one word for all of them sends somebody to check the wrong thing.
|
|
*/
|
|
<span className="text-destructive">Not answering — {health.reason ?? 'no reason given'}.</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|