carry the invite's device name in the link so the phone can prefill it

This commit is contained in:
2026-08-06 13:24:45 +00:00
parent 2873948f98
commit 33ecfa989d
2 changed files with 23 additions and 4 deletions
+20 -1
View File
@@ -95,6 +95,25 @@ async function relay(res: Response | string, wrap: (body: Record<string, unknown
return Response.json(wrap(body)); return Response.json(wrap(body));
} }
/**
* Carry the admin's device name in the link's fragment, as `n=<percent-encoded>`.
*
* 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. * `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 raw = body.invite ?? body;
const invite = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}; const invite = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const { deepLink: _deepLink, ...rest } = invite; const { deepLink: _deepLink, ...rest } = invite;
return { available: true, invite: rest }; return { available: true, invite: { ...rest, url: withNameHint(rest.url, input.note) } };
}); });
} }
@@ -201,11 +201,11 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
</label> </label>
<Field <Field
label="Note (optional)" label="Device name (optional)"
value={note} value={note}
onChange={setNote} onChange={setNote}
placeholder="Andre's wife iPhone" placeholder="andre-iphone"
hint="Only for your own list — it is never shown to whoever receives the link." hint="Prefilled on the phone's join screen and used as the node's name, which the person can edit. It labels this invite in your list too."
/> />
<label className="flex flex-col gap-1.5"> <label className="flex flex-col gap-1.5">