kiosk: the authenticated API and two panels

what the mobile app needs is now buildable against something real: GET /apps,
GET /key, GET /install/:app/:platform/:version, POST /installs, plus device and
build verbs. all under /api/kiosk, all behind a platform session.

the install URL is built per request and never stored, because it carries the
CALLING user's key - two people asking for the same build get different links,
which is the point: the artefact route is public and the key is what authorises
it.

deliberately NOT here: the four requests that arrive with no session. installd
fetching the manifest and the ipa, the device posting its udid, safari
downloading a mobileconfig. a plugin cannot serve an unauthenticated route
today, so those wait for the platform primitive and will land in api/public.ts
- a separate file so the two surfaces cannot be confused by whoever reads
either one.

so /install returns a correct URL pointing at a route that does not exist yet.
that is the shape the app should build against.

layout is a horizontal root with the app list first, and that is load-bearing:
on a phone WorkspaceRenderer collapses horizontal groups to children[0] and
PluginScreen passes no mobilePanelId, so first is the only panel a phone shows.
the install page is the one page that must work in mobile safari.

PUBLIC_URL is read from the environment rather than officer-url.mjs, which
exports only loopback URLs - ios is not on loopback, and it needs https with a
cert it already trusts or it declines silently.
This commit is contained in:
Claude Opus 5
2026-08-18 03:07:56 +00:00
parent 91763f4bf4
commit b729aa3a67
9 changed files with 709 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import { useQuery } from '@tanstack/react-query';
import { Download, Loader2, Smartphone } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import type { KioskApp, KioskBuild } from '../db/types';
/*
The install list. This is the page that has to work on a phone in mobile Safari — see `layout.ts` for why
it is first in a horizontal root.
Deliberately plain: rows and a button. Somebody is holding a phone, and the whole content is "which apps
exist and which one do I want".
*/
const prettyVersion = (v: string) =>
v.length === 10 ? `${v.slice(0, 4)}.${v.slice(4, 6)}.${v.slice(6, 8)}.${v.slice(8)}` : v;
const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
const InstallButton = ({ build }: { build: KioskBuild }) => {
const client = useClient();
// The link is fetched at the moment of the tap, never rendered ahead of time: it embeds the caller's key,
// and the key is re-read on demand precisely so rotating one costs nothing.
const install = async () => {
const { url } = await client.get<{ url: string; kind: 'itms' | 'apk' }>(
`/kiosk/install/${encodeURIComponent(build.appName)}/${build.platform}/${encodeURIComponent(build.version)}`,
);
// `itms-services://` is handed to the OS, which takes over from here. There is no completion callback —
// the app reports its own installs afterwards.
window.location.href = url;
};
return (
<button
type="button"
onClick={() => void install()}
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
<Download className="h-3.5 w-3.5" />
Install
</button>
);
};
const BuildRow = ({ build }: { build: KioskBuild }) => (
<div className="flex items-center gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{build.displayName}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5 font-medium uppercase">{build.platform}</span>
<span className="tabular-nums">{prettyVersion(build.version)}</span>
<span>{build.shortVersion}</span>
<span>{mb(build.sizeBytes)}</span>
{build.signing ? <span>{build.signing}</span> : null}
{/* The date the app stops launching. Absent for an unsigned build, which has no profile. */}
{build.profileExpiresAt ? (
<span title="The provisioning profile expires — the app stops launching then">
expires {new Date(build.profileExpiresAt).toISOString().slice(0, 10)}
</span>
) : null}
</div>
</div>
<InstallButton build={build} />
</div>
);
export const AppsPanel = () => {
const client = useClient();
const { data, isLoading } = useQuery({
queryKey: ['kiosk', 'apps'],
queryFn: () => client.get<{ apps: KioskApp[] }>('/kiosk/apps'),
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
const apps = data?.apps ?? [];
if (!apps.length) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm text-muted-foreground">
<Smartphone className="h-8 w-8" />
<span>No builds published yet.</span>
<span className="text-xs">Builds are published from the machine that signs them.</span>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
{apps.map((app) => (
<div key={app.appName}>
{app.ios ? <BuildRow build={app.ios} /> : null}
{app.android ? <BuildRow build={app.android} /> : null}
</div>
))}
</div>
);
};
+93
View File
@@ -0,0 +1,93 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Tablet, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import type { KioskDevice } from '../db/types';
/*
Registered devices — yours, or everyone's if you are the owner. The server decides which; this panel does
not ask, so a member cannot request somebody else's list by omitting a parameter.
Enrolment itself is not here. Capturing a UDID needs a `.mobileconfig` served without a session and a
callback the device posts to, and neither can exist until the platform can serve a plugin's route above
the account gate. Until then this shows what the standalone service registered.
*/
const STATUS_TONE: Record<string, string> = {
registered: 'bg-success/10 text-success',
captured: 'bg-info/10 text-info',
pending: 'bg-muted text-muted-foreground',
failed: 'bg-destructive/10 text-destructive',
};
export const DevicesPanel = () => {
const client = useClient();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['kiosk', 'devices'],
queryFn: () => client.get<{ devices: KioskDevice[] }>('/kiosk/devices'),
});
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/kiosk/devices/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['kiosk', 'devices'] }),
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
const devices = data?.devices ?? [];
return (
<div className="h-full overflow-y-auto">
<div className="sticky top-0 z-10 border-b border-border bg-muted px-4 py-2 text-xs font-semibold">
Devices
</div>
{!devices.length ? (
<div className="flex flex-col items-center justify-center gap-2 px-6 py-10 text-center text-sm text-muted-foreground">
<Tablet className="h-7 w-7" />
<span>No devices registered.</span>
<span className="text-xs">
A device is registered by installing an enrolment profile, which is not wired up yet.
</span>
</div>
) : (
devices.map((device) => (
<div key={device.id} className="group flex items-center gap-3 border-b border-border/60 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{device.label}</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className={`rounded px-1.5 py-0.5 font-medium ${STATUS_TONE[device.status] ?? 'bg-muted'}`}>
{device.status}
</span>
{device.product ? <span>{device.product}</span> : null}
{device.osVersion ? <span>iOS {device.osVersion}</span> : null}
{/* The UDID is deliberately not shown in full: it identifies a person's hardware, and the
whole reason artefacts stay private is that a `.ipa` leaks the full list of them. */}
{device.udid ? <span className="tabular-nums">…{device.udid.slice(-6)}</span> : null}
{device.failureReason ? (
<span className="text-destructive">{device.failureReason}</span>
) : null}
</div>
</div>
<button
type="button"
onClick={() => remove.mutate(device.id)}
title="Remove device"
aria-label={`Remove ${device.label}`}
className="shrink-0 cursor-pointer rounded p-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
import type { LayoutNode } from 'officerdev';
/*
Horizontal root, app list first — and that is load-bearing rather than aesthetic.
On a phone, `WorkspaceRenderer` collapses a HORIZONTAL group to one child at a time and defaults to
`children[0]`. A vertical group is not collapsed at all: every panel stacks into the viewport. And
`PluginScreen` passes no `mobilePanelId`, so whatever is first is the only panel a phone will ever show.
That matters more here than anywhere else in the platform, because the install page is the one page that
MUST work in mobile Safari — you cannot install the Kiosk app from the Kiosk app.
*/
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'kiosk-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'kiosk-apps', appType: 'kiosk-apps' }, size: 55 },
{ node: { type: 'panel', id: 'kiosk-devices', appType: 'kiosk-devices' }, size: 45 },
],
};
+14
View File
@@ -0,0 +1,14 @@
import type { AppRegistryMeta } from 'officerdev';
import { Smartphone, Tablet } from 'lucide-react';
import { AppsPanel } from './AppsPanel';
import { DevicesPanel } from './DevicesPanel';
// `panels.ts`, not `.tsx` — discovery checks for that exact filename and refuses the plugin by name if it
// is missing, so components live in their own files and this stays a declaration.
//
// `availableOnPanel: false` throughout: PluginScreen renders the workspace `locked`, so these are not
// swappable and offering them in a picker would be a lie.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'kiosk-apps', name: 'Apps', icon: Smartphone, component: AppsPanel, availableOnPanel: false },
{ key: 'kiosk-devices', name: 'Devices', icon: Tablet, component: DevicesPanel, availableOnPanel: false },
];