The fourth plugin out, after offscale, music and email. Two routers, a sidecar,
three tables and — new — a settings screen, since the vault never had a UI in
the platform.
What it is: a reverse-proxy to a Vaultwarden you already run, plus a credential
broker that keeps the vault session server-side so devices never hold one. It
stores no passwords and cannot read any; every byte through it is ciphertext.
Two things changed rather than moved:
• VAULTWARDEN_URL left .env for a one-row `vault_config` table with a panel
writing it. A plugin cannot ask the owner to edit a file and restart a
process, so the env var could not survive the move. Server-wide, decided
with the owner: one Vaultwarden per install. The sidecar reads it on a 10s
cache rather than per request, since it sits in front of every proxied byte.
• Core stopped importing clearVaultTokens/clearVaultUnlockKey from three auth
handlers. That is the security property behind the distress and panic
buttons, so it could not simply be dropped — the platform gained a small
account-lifecycle hook instead, and the severity mapping moved here where it
belongs. Core now names no table and no column of this plugin.
Two things were deleted rather than carried, both already dark since
2026-08-13 and both recorded in PLUGIN.md:
• The unauthenticated Bitwarden passthrough. A plugin's routes mount behind
Officer's account gate; top-level unauthenticated prefixes are not something
a manifest can declare. Its own comments already called it TEMPORARY,
pending a first-party browser extension that authenticates to Officer.
• The notifications websocket. No plugin owns a socket on this platform yet.
SignalR falls back to HTTP long-polling on its own, so clients still sync.
250 lines
9.3 KiB
TypeScript
250 lines
9.3 KiB
TypeScript
import { useCallback, 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;
|
|
};
|
|
|
|
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);
|
|
|
|
const loadConfig = useCallback(async () => {
|
|
try {
|
|
const next = await get<VaultConfig>('/vault/config');
|
|
setConfig(next);
|
|
setUrl(next.url ?? '');
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'could not read the vault configuration');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [get]);
|
|
|
|
// 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.
|
|
const probe = useCallback(async () => {
|
|
setProbing(true);
|
|
try {
|
|
setHealth(await get<HealthResult>('/vault/config/health'));
|
|
} catch (err) {
|
|
setHealth({ ok: false, error: err instanceof Error ? err.message : 'probe failed' });
|
|
} finally {
|
|
setProbing(false);
|
|
}
|
|
}, [get]);
|
|
|
|
useEffect(() => {
|
|
void loadConfig();
|
|
}, [loadConfig]);
|
|
|
|
// Only probe once there is somewhere to probe. Asking about an unconfigured vault returns a 503 that
|
|
// reads on screen as a fault, when the honest answer is "you have not set this up yet".
|
|
useEffect(() => {
|
|
if (config?.configured) void probe();
|
|
}, [config?.configured, probe]);
|
|
|
|
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">
|
|
<Pill tone={health.ok ? 'success' : 'danger'}>{health.ok ? 'Answering' : 'No answer'}</Pill>
|
|
{health.ms !== undefined && <span>{health.ms}ms</span>}
|
|
{health.upstreamStatus !== undefined && <span>HTTP {health.upstreamStatus}</span>}
|
|
</div>
|
|
{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>
|
|
);
|
|
};
|