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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user