From 33ecfa989d65a31b658f0b580228ac3ac5115b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 6 Aug 2026 13:24:45 +0000 Subject: [PATCH] carry the invite's device name in the link so the phone can prefill it --- src/servers/sidecar/headscale/invites.ts | 21 ++++++++++++++++++- .../src/apps/Headscale/InvitesView.tsx | 6 +++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/servers/sidecar/headscale/invites.ts b/src/servers/sidecar/headscale/invites.ts index c3cb2b05..07f83876 100644 --- a/src/servers/sidecar/headscale/invites.ts +++ b/src/servers/sidecar/headscale/invites.ts @@ -95,6 +95,25 @@ async function relay(res: Response | string, wrap: (body: Record`. + * + * The companion already knows the name — it stores the note and hands it back as `suggestedHostname` on + * claim — but a claim only happens when the person taps Join, which is one step AFTER the screen that asks + * them to name the device. So the name has to arrive with the link if the field is to be prefilled, and the + * link is the last thing that passes through here. + * + * Safe at every hop: the fragment is never sent to a server, the companion's /join page copies it verbatim + * into the `officer-offscale://` deep link, and a build of the app that predates this ignores an unknown + * parameter and still gets the name from `suggestedHostname` at claim time. Percent-encoded rather than + * base64url (which `s` uses) because the app's fragment parser already decodeURIComponent()s every value, + * and because base64url of a non-ASCII name would decode to mojibake on Hermes. + */ +function withNameHint(url: unknown, name: string | undefined): unknown { + if (typeof url !== 'string' || !name || !url.includes('#')) return url; + return `${url}&n=${encodeURIComponent(name)}`; +} + /** * `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once. * @@ -112,7 +131,7 @@ async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): P const raw = body.invite ?? body; const invite = raw && typeof raw === 'object' ? (raw as Record) : {}; const { deepLink: _deepLink, ...rest } = invite; - return { available: true, invite: rest }; + return { available: true, invite: { ...rest, url: withNameHint(rest.url, input.note) } }; }); } diff --git a/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx b/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx index 811c13a6..b68964fe 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx @@ -201,11 +201,11 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {