a send form for SendOff, and no receiving one

/sendoff — pick files, pick one of your own machines or a person who has allowed
you, send. The consent list sits on the same screen rather than in settings,
because the two questions arrive together: the first time somebody opens this and
finds nobody to send to, the reason is that consent runs the other way, and the
answer has to be visible where the question was asked.

There is deliberately no received view. Files land in Documents/SendOff/ inside
the recipient's own tree, so the file browser already IS the inbox — a second view
of the same directory would be a worse copy of one that works, kept in sync by
hand.

Two details worth keeping. The file input resets its value after every pick, or
choosing the same file twice in a row does nothing, because `change` does not fire
on an unchanged value. And a send clears the files but keeps the target, since
several batches to the same place is the common second action and re-picking it
each time is the sort of small tax that stops somebody using a thing.

The multipart goes through `client.post`, which already recognises a FormData and
drops the JSON content-type so the browser picks the boundary. That was the only
thing making this look like it needed a raw fetch, and a raw fetch would have
carried its own copy of the token resolution — which is a chain of seven sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 02:47:47 +00:00
co-authored by Claude Opus 5
parent b22e9d86c0
commit ca7d3c657b
10 changed files with 347 additions and 0 deletions
+1
View File
@@ -107,6 +107,7 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} /> <Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} /> <Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} /> <Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} /> <Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -155,6 +155,7 @@ import {
Store, Store,
Puzzle, Puzzle,
Radar, Radar,
Send,
} from 'lucide-react'; } from 'lucide-react';
/** /**
@@ -180,6 +181,10 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, { label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because it is the door into another account's filesystem, and the permission that gates it is
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
// every other grantable tile here gets.
{ label: 'SendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and // Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
// contacts became core, and deleting that entry took their tiles with it — the routes kept working // contacts became core, and deleting that entry took their tiles with it — the routes kept working
// while the dock and the settings list went silent about them, which is exactly the shape of failure // while the dock and the settings list went silent about them, which is exactly the shape of failure
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /sendoff — pick files, pick a machine of your own or a person who has allowed you, send.
//
// There is no receiving half. Files land in `Documents/SendOff/` inside the recipient's own tree, so the
// file browser already IS the inbox — and a second view of the same directory would be a worse copy of
// one that already works, kept in sync by hand.
export const SendOffScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/sendoff', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['sendoff'], fallback: 'sendoff' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The form is a column and there is nothing to put beside it — receiving already has a UI,
// which is the file browser.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'sendoff',
appType: 'sendoff',
};
@@ -0,0 +1 @@
export * from './SendOffScreen';
@@ -23,3 +23,4 @@ export * from './Terminal';
export * from './Jobs'; export * from './Jobs';
export * from './QrTransfer'; export * from './QrTransfer';
export * from './Minesweeper'; export * from './Minesweeper';
export * from './SendOff';
@@ -12,6 +12,7 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper'; import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
import { appRegistryMetas as davMetas } from '../apps/Dav'; import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ'; import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import type { QueryClient } from '@tanstack/react-query'; import type { QueryClient } from '@tanstack/react-query';
@@ -30,6 +31,7 @@ export const apps = [
...monitorMetas, ...monitorMetas,
...qrTransferMetas, ...qrTransferMetas,
...minesweeperMetas, ...minesweeperMetas,
...sendOffMetas,
...davMetas, ...davMetas,
...commandHqMetas, ...commandHqMetas,
...appStoreMetas, ...appStoreMetas,
@@ -0,0 +1,219 @@
import { useRef, useState } from 'react';
import { Laptop, Send, Upload, User, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
const kb = (n: number): string =>
n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; name: string };
/**
* SendOff — pick files, pick where, send.
*
* Deliberately has no "received" view. Files land in `Documents/SendOff/` in the recipient's own tree, so
* the file browser is already the receiving UI and a second one here would be a worse copy of it that
* needed keeping in sync.
*/
export const SendOffPanel = () => {
const { devices, people, isLoading } = useSendOffTargets();
const send = useSendOff();
const [files, setFiles] = useState<File[]>([]);
const [target, setTarget] = useState<Target | null>(null);
const picker = useRef<HTMLInputElement>(null);
const total = files.reduce((n, f) => n + f.size, 0);
const ready = files.length > 0 && target !== null;
const doSend = async () => {
if (!target) return;
await send
.mutateAsync({ files, target: target.kind === 'machine' ? { machine: target.name } : { toUser: target.id } })
// Clear only the files. The target stays selected because sending several batches to the same place
// is the common second action, and re-picking it every time is the kind of small tax that stops
// somebody using a thing.
.then(() => setFiles([]))
.catch(() => {});
};
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
<section>
<h3 className="text-sm font-semibold text-foreground">Files</h3>
<input
ref={picker}
type="file"
multiple
className="hidden"
onChange={(ev) => {
setFiles((prev) => [...prev, ...Array.from(ev.target.files ?? [])]);
// Reset the input, or picking the same file twice in a row does nothing: `change` does not
// fire when the value is unchanged.
ev.target.value = '';
}}
/>
<Button variant="outline" className="mt-2 w-full justify-start" onClick={() => picker.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
{files.length ? 'Add more files…' : 'Choose files…'}
</Button>
{files.length > 0 && (
<ul className="mt-2 flex flex-col gap-1">
{files.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1 text-sm"
>
<span className="truncate text-foreground">{f.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
<button
type="button"
aria-label={`Remove ${f.name}`}
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
<li className="px-2 pt-1 text-xs text-muted-foreground">
{files.length} file{files.length === 1 ? '' : 's'} · {kb(total)}
</li>
</ul>
)}
</section>
<section>
<h3 className="text-sm font-semibold text-foreground">Where</h3>
{isLoading ? (
<p className="mt-2 text-sm text-muted-foreground">Loading…</p>
) : (
<div className="mt-2 flex flex-col gap-3">
<TargetGroup
icon={Laptop}
heading="My machines"
empty="No machines registered yet — an Officer app registers itself on start."
options={devices.map((d) => ({
key: `m:${d.id}`,
name: d.name,
target: { kind: 'machine' as const, name: d.name },
}))}
selected={target}
onSelect={setTarget}
/>
<TargetGroup
icon={User}
heading="People"
empty="Nobody has allowed you to send to them yet."
options={people.map((p) => ({
key: `u:${p.id}`,
name: label(p),
target: { kind: 'user' as const, id: p.id, name: label(p) },
}))}
selected={target}
onSelect={setTarget}
/>
</div>
)}
</section>
<section className="flex flex-col gap-2">
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
<Send className="mr-2 h-4 w-4" />
{send.isPending ? 'Sending…' : target ? `Send to ${target.name}` : 'Send'}
</Button>
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
revoked you are different problems with different answers. */}
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
{send.data && (
<p className="text-sm text-muted-foreground">
Landed in <span className="text-foreground">{send.data.path}</span> —{' '}
{send.data.files.map((f) => f.name).join(', ')}
</p>
)}
</section>
<WhoMaySend />
</div>
</div>
);
};
type TargetGroupProps = {
icon: typeof Laptop;
heading: string;
empty: string;
options: { key: string; name: string; target: Target }[];
selected: Target | null;
onSelect: (t: Target) => void;
};
const sameTarget = (a: Target | null, b: Target): boolean =>
a !== null &&
a.kind === b.kind &&
(b.kind === 'machine' ? a.kind === 'machine' && a.name === b.name : a.kind === 'user' && a.id === b.id);
const TargetGroup = ({ icon: Icon, heading, empty, options, selected, onSelect }: TargetGroupProps) => (
<div>
<p className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
{heading}
</p>
{options.length === 0 ? (
<p className="mt-1 text-sm text-muted-foreground">{empty}</p>
) : (
<div className="mt-1 flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o.key}
size="sm"
variant={sameTarget(selected, o.target) ? 'default' : 'outline'}
onClick={() => onSelect(o.target)}
>
{o.name}
</Button>
))}
</div>
)}
</div>
);
/**
* The consent list, on the same screen as sending.
*
* It belongs here rather than in settings because the two questions arrive together: the first time
* somebody opens SendOff and finds nobody to send to, the reason is that consent runs the other way, and
* the answer has to be visible in the same place the question was asked.
*/
const WhoMaySend = () => {
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
if (isLoading || candidates.length === 0) return null;
return (
<section className="border-t border-border pt-4">
<h3 className="text-sm font-semibold text-foreground">Who may send to me</h3>
<p className="mt-1 text-xs text-muted-foreground">
Files from anyone you allow land in <span className="text-foreground">Documents/SendOff/their-name</span>.
Turning someone off stops future sends and leaves what they already sent alone.
</p>
<div className="mt-2 flex flex-col gap-1">
{candidates.map((p) => (
<label
key={p.id}
className="flex items-center justify-between gap-2 rounded border border-border px-2 py-1.5 text-sm"
>
<span className="truncate text-foreground">{label(p)}</span>
<input
type="checkbox"
checked={p.allowed}
disabled={isSaving}
onChange={(ev) => void setAllowed(p.id, ev.target.checked).catch(() => {})}
/>
</label>
))}
</div>
</section>
);
};
@@ -0,0 +1,10 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Send } from 'lucide-react';
import { SendOffPanel } from './SendOffPanel';
export { SendOffPanel } from './SendOffPanel';
export { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'sendoff', name: 'SendOff', icon: Send, component: SendOffPanel },
];
@@ -0,0 +1,83 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type SendOffDevice = { id: string; name: string; platform: string | null; lastSeenAt: string };
export type SendOffPerson = { id: number; email: string; name: string | null };
type Targets = { devices: SendOffDevice[]; people: SendOffPerson[] };
type Senders = { allowed: SendOffPerson[]; candidates: (SendOffPerson & { allowed: boolean })[] };
const TARGETS = ['sendoff', 'targets'];
const SENDERS = ['sendoff', 'senders'];
/** Where a file can go: my own named machines, and the accounts that have allowed me. */
export function useSendOffTargets() {
const client = useClient();
const { data, isLoading, error } = useQuery<Targets>({
queryKey: TARGETS,
queryFn: () => client.get<Targets>('/sendoff/targets'),
// A machine registered from a phone a moment ago should appear without a reload, and this is a
// two-row query — the panel is the only reader and it is open for seconds at a time.
staleTime: 0,
});
return { devices: data?.devices ?? [], people: data?.people ?? [], isLoading, error };
}
/** Who I have allowed to send to me — the consent list, which only I can change. */
export function useSendOffSenders() {
const client = useClient();
const qc = useQueryClient();
const { data, isLoading } = useQuery<Senders>({
queryKey: SENDERS,
queryFn: () => client.get<Senders>('/sendoff/senders'),
staleTime: 0,
});
const allow = useMutation({
mutationFn: (userId: number) => client.post('/sendoff/senders', { userId }),
// Both: granting changes who may send to ME, and on somebody else's screen it changes who they may
// send TO. Only this browser's cache is reachable from here, so it invalidates its own of each.
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SENDERS });
void qc.invalidateQueries({ queryKey: TARGETS });
},
});
const revoke = useMutation({
mutationFn: (userId: number) => client.delete(`/sendoff/senders/${userId}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: SENDERS });
void qc.invalidateQueries({ queryKey: TARGETS });
},
});
return {
candidates: data?.candidates ?? [],
isLoading,
setAllowed: (userId: number, allowed: boolean) =>
allowed ? allow.mutateAsync(userId) : revoke.mutateAsync(userId),
isSaving: allow.isPending || revoke.isPending,
};
}
export type SendResult = { path: string; files: { name: string; size: number }[] };
/**
* The send itself — multipart, through the ordinary client.
*
* `client.post` already recognises a `FormData` and drops the JSON content-type when it sees one, so the
* browser picks the boundary. That is the only thing that made this look like it needed a raw `fetch`.
*/
export function useSendOff() {
const client = useClient();
const qc = useQueryClient();
return useMutation<SendResult, Error, { files: File[]; target: { machine: string } | { toUser: number } }>({
mutationFn: ({ files, target }) => {
const form = new FormData();
for (const f of files) form.append('file', f);
if ('machine' in target) form.append('machine', target.machine);
else form.append('toUser', String(target.toUser));
return client.post<SendResult>('/sendoff/send', form);
},
onSuccess: () => void qc.invalidateQueries({ queryKey: TARGETS }),
});
}