Files
platform/plugins/offscale/web/UsersView.tsx
T
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00

204 lines
7.4 KiB
TypeScript

import { useState } from 'react';
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
import type { HeadscaleUserWithCounts } from './shared';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo } from './format';
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
// The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login.
//
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
const UserRow = ({ user, onError }: UserRowProps) => {
const { rename, remove } = useHeadscaleUsers();
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState(user.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draft.trim();
setRenaming(false);
if (!name || name === user.name) return;
await run(() => rename.mutateAsync({ id: user.id, name }));
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={user.onlineCount > 0 ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submitRename();
if (ev.key === 'Escape') setRenaming(false);
}}
autoFocus
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50"
/>
<button type="button" onClick={() => void submitRename()} className="cursor-pointer p-1 text-emerald-400">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{user.name}</span>
{user.provider && <Badge>{user.provider}</Badge>}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'}
{user.onlineCount > 0 && `, ${user.onlineCount} online`}
</span>
{user.email && <span>· {user.email}</span>}
<span>· created {timeAgo(user.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraft(user.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(user.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
{user.nodeCount > 0 ? `Delete with ${user.nodeCount} node(s)` : 'Confirm delete'}
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
const { create } = useHeadscaleUsers();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
if (!name.trim()) return setError('A name is required');
try {
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New user</div>
<Field
label="Name"
value={name}
onChange={setName}
placeholder="laptop-fleet"
hint="Lowercase, no spaces. This is the namespace nodes and keys belong to."
autoFocus
/>
<Field label="Email (optional)" value={email} onChange={setEmail} placeholder="someone@example.com" />
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create user
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
export const UsersView = () => {
const { users, isLoading, error } = useHeadscaleUsers();
const [creating, setCreating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
return (
<ViewShell isLoading={isLoading} error={error} label="users">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Users</h2>
<p className="mt-0.5 text-xs text-zinc-500">Namespaces that own nodes and pre-auth keys.</p>
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New user
</Button>
)}
</div>
{creating && <CreateUserForm onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{users.length === 0 && !creating && (
<EmptyBody
icon={<Users className="h-6 w-6" />}
title="No users yet"
hint="Every node belongs to a user. Create one before issuing a pre-auth key."
/>
)}
{users.map((user) => (
<UserRow key={user.id} user={user} onError={setActionError} />
))}
</div>
</ViewShell>
);
};