Typing was impossible — each character vanished, and pasting flashed and
reverted to the saved value.
`useClient()` calls `createClient()` on every render with no memoisation, so
`get`/`put` are new function identities each time. The panel wrapped its
fetches in `useCallback(..., [get])`, which therefore also changed identity
every render, and the effects depended on THOSE:
keystroke -> setUrl -> re-render -> new `get` -> new callback -> effect fires
-> GET /config -> setUrl(saved value) -> character gone
Fixed by making them plain functions and loading once on mount, with the health
probe fired from the load rather than from a second effect watching
`configured` — an effect is one more thing that can re-fire.
The rule, written into the file since nothing about the symptom pointed at a
dependency array: never put a value derived from `useClient()` in a dependency
array. The rest of the codebase already follows it (CommandHqDetail keys its
fetch effect on `sessionId` alone).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { KeyRound, Loader2, RefreshCw } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { toneFill, toneText, type Tone } from '@/components/Data';
|
|
|
|
// The whole /vault screen: point this install at a Vaultwarden, and see whether it answers.
|
|
//
|
|
// There is no cipher list here and there will not be one. The vault is end-to-end encrypted and this
|
|
// plugin is a credential broker plus a reverse-proxy — the things that read vault items are the Bitwarden
|
|
// apps and Officer's own mobile client, both of which hold the keys this server never sees.
|
|
//
|
|
// Every colour is a semantic token (see components/Data/tone.ts). A plugin that reaches for a palette
|
|
// class stops following the theme, which is the one rule `plugins/EXTRACTING-A-PLUGIN.md` states twice.
|
|
|
|
type VaultConfig = {
|
|
url: string | null;
|
|
enabled: boolean;
|
|
updatedAt: string | null;
|
|
configured: boolean;
|
|
};
|
|
|
|
type HealthResult = {
|
|
ok: boolean;
|
|
error?: string;
|
|
upstreamStatus?: number;
|
|
ms?: number;
|
|
url?: string;
|
|
/** `server.name` from Vaultwarden's own `/api/config`, or null if it did not identify itself. */
|
|
server?: string | null;
|
|
version?: string | null;
|
|
/** Answered 200 AND said it is a Vaultwarden. False means something else is on that address. */
|
|
identified?: boolean;
|
|
};
|
|
|
|
const Field = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
|
|
const Pill = ({ tone, children }: { tone: Tone; children: React.ReactNode }) => (
|
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${toneFill[tone]}`}>{children}</span>
|
|
);
|
|
|
|
export const VaultPanel = () => {
|
|
const { get, put } = useClient();
|
|
|
|
const [config, setConfig] = useState<VaultConfig | null>(null);
|
|
const [url, setUrl] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [health, setHealth] = useState<HealthResult | null>(null);
|
|
const [probing, setProbing] = useState(false);
|
|
|
|
/*
|
|
Plain functions, NOT `useCallback`, and the effect below depends on NEITHER of them.
|
|
|
|
`useClient()` calls `createClient()` on every render with no memoisation, so `get`/`put` are fresh
|
|
function identities each time. A `useCallback(..., [get])` therefore also changes identity every
|
|
render, and an effect depending on THAT re-runs every render. The result was a panel that wiped its
|
|
own input:
|
|
|
|
keystroke -> setUrl -> re-render -> new `get` -> new callback -> effect fires
|
|
-> GET /config -> setUrl(saved value) -> the character just typed disappears
|
|
|
|
Typing was impossible, pasting flashed and reverted. Nothing about it looked like a dependency-array
|
|
problem from the outside, which is what made it worth writing down rather than just fixing.
|
|
|
|
The rule this settles for anything in this plugin: never put a value derived from `useClient()` in a
|
|
dependency array. The rest of the codebase already follows it (see `CommandHqDetail`, which keys its
|
|
fetch effect on `sessionId` alone).
|
|
*/
|
|
const probe = async () => {
|
|
setProbing(true);
|
|
try {
|
|
// Never throws: an unreachable vault is the thing this panel exists to SHOW, not an error state for
|
|
// the panel itself. A rejected probe becomes a result with `ok: false`, same as a 502 body would.
|
|
setHealth(await get<HealthResult>('/vault/config/health'));
|
|
} catch (err) {
|
|
setHealth({ ok: false, error: err instanceof Error ? err.message : 'probe failed' });
|
|
} finally {
|
|
setProbing(false);
|
|
}
|
|
};
|
|
|
|
const loadConfig = async () => {
|
|
try {
|
|
const next = await get<VaultConfig>('/vault/config');
|
|
setConfig(next);
|
|
setUrl(next.url ?? '');
|
|
setError(null);
|
|
// Probed here rather than from an effect watching `configured`, for the same reason: an effect is
|
|
// one more thing that can re-fire. Only when there is somewhere to probe — asking about an
|
|
// unconfigured vault returns a 503 that reads as a fault when the honest answer is "not set up yet".
|
|
if (next.configured) void probe();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'could not read the vault configuration');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Mount only. Deliberately empty deps — see the note above.
|
|
useEffect(() => {
|
|
void loadConfig();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const next = await put<VaultConfig>('/vault/config', { url: url.trim() || null });
|
|
setConfig(next);
|
|
setUrl(next.url ?? '');
|
|
// The sidecar re-reads its cached URL on a 10s TTL (see sidecar/upstream.ts), so a probe fired
|
|
// immediately would still be answered against the OLD upstream and look like the save failed.
|
|
setHealth(null);
|
|
setTimeout(() => void probe(), 10_500);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'could not save');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const toggleEnabled = async () => {
|
|
if (!config) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
setConfig(await put<VaultConfig>('/vault/config', { enabled: !config.enabled }));
|
|
setHealth(null);
|
|
setTimeout(() => void probe(), 10_500);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'could not save');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Loading…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const dirty = (config?.url ?? '') !== url.trim();
|
|
|
|
const statusTone: Tone = !config?.configured ? 'neutral' : !config.enabled ? 'warning' : health?.ok ? 'success' : health ? 'danger' : 'busy';
|
|
const statusLabel = !config?.configured
|
|
? 'Not configured'
|
|
: !config.enabled
|
|
? 'Disabled'
|
|
: health?.ok
|
|
? 'Reachable'
|
|
: health
|
|
? 'Unreachable'
|
|
: 'Checking…';
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto">
|
|
<div className="mx-auto flex max-w-2xl flex-col gap-6 p-6">
|
|
<header className="flex items-start gap-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
|
<KeyRound className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold">Vault</h1>
|
|
<Pill tone={statusTone}>{statusLabel}</Pill>
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Point Officer at a Vaultwarden you already run. Officer holds the vault session so your
|
|
devices never have to — they sign in to Officer instead.
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="flex flex-col gap-4 rounded-xl border border-border p-4">
|
|
<Field label="Vaultwarden URL">
|
|
<input
|
|
value={url}
|
|
onChange={(ev) => setUrl(ev.target.value)}
|
|
onKeyDown={(ev) => {
|
|
if (ev.key === 'Enter' && dirty && !saving) void save();
|
|
}}
|
|
placeholder="https://vault.example.com"
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</Field>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
The address of your own Vaultwarden server. Officer never installs or manages it — it forwards
|
|
to it, and stores nothing from inside it.
|
|
</p>
|
|
|
|
{error && <p className={`text-sm ${toneText.danger}`}>{error}</p>}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void save()}
|
|
disabled={!dirty || saving}
|
|
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
|
>
|
|
{saving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
{dirty && !saving && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setUrl(config?.url ?? '')}
|
|
className="rounded-lg px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
Cancel
|
|
</button>
|
|
)}
|
|
{config?.configured && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void toggleEnabled()}
|
|
disabled={saving}
|
|
className="ml-auto rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
|
>
|
|
{config.enabled ? 'Disable' : 'Enable'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{config?.configured && (
|
|
<section className="flex flex-col gap-3 rounded-xl border border-border p-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Connection</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => void probe()}
|
|
disabled={probing}
|
|
className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
|
|
>
|
|
<RefreshCw className={`h-3.5 w-3.5 ${probing ? 'animate-spin' : ''}`} />
|
|
Check
|
|
</button>
|
|
</div>
|
|
|
|
{health ? (
|
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-2">
|
|
{/* Three outcomes, not two. "Answered but is not a Vaultwarden" is its own state and the
|
|
main reason this check exists — pointing at the wrong port on a busy host gives a
|
|
perfectly good 200 from something that can never serve a vault. */}
|
|
<Pill tone={health.ok ? (health.identified ? 'success' : 'warning') : 'danger'}>
|
|
{health.ok ? (health.identified ? 'Vaultwarden' : 'Answered, but not a Vaultwarden') : 'No answer'}
|
|
</Pill>
|
|
{health.version && <span>v{health.version}</span>}
|
|
{health.ms !== undefined && <span>{health.ms}ms</span>}
|
|
{health.upstreamStatus !== undefined && <span>HTTP {health.upstreamStatus}</span>}
|
|
</div>
|
|
{health.ok && !health.identified && (
|
|
<span className={toneText.warning}>
|
|
Something is listening there, but it did not identify itself as a Vaultwarden. Check the
|
|
port.
|
|
</span>
|
|
)}
|
|
{health.error && <span className={toneText.danger}>{health.error}</span>}
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">
|
|
{probing ? 'Checking…' : 'Not checked since the last change.'}
|
|
</span>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|