Skip to content

Commit 3d107f5

Browse files
committed
feat(onboarding): add node-key registration and user creation options
- Add register-node action for manual device registration via node key - Add create-user action when Headscale isn't using OIDC - Show different UI flows based on headscale OIDC config - Wire up dialogs for both registration methods Closes #387
1 parent 6470f5a commit 3d107f5

File tree

1 file changed

+243
-10
lines changed

1 file changed

+243
-10
lines changed

app/routes/users/onboarding.tsx

Lines changed: 243 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Icon } from "@iconify/react";
2-
import { ArrowRight } from "lucide-react";
3-
import { useEffect } from "react";
4-
import { Form, NavLink } from "react-router";
2+
import { ArrowRight, Key, UserPlus } from "lucide-react";
3+
import { useEffect, useState } from "react";
4+
import { data, Form, NavLink, useFetcher } from "react-router";
55

66
import Button from "~/components/Button";
77
import Card from "~/components/Card";
8+
import Dialog from "~/components/Dialog";
9+
import Input from "~/components/Input";
810
import Link from "~/components/link";
11+
import Notice from "~/components/Notice";
912
import Options from "~/components/Options";
1013
import StatusCircle from "~/components/StatusCircle";
1114
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
@@ -54,6 +57,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
5457
}
5558
}
5659

60+
const headscaleOidcEnabled = !!context.hs.c?.oidc;
61+
5762
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
5863
const api = context.hsApi.getRuntimeClient(apiKey);
5964

@@ -66,11 +71,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
6671
try {
6772
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
6873

74+
headscaleUsers = apiUsers.map((u) => ({
75+
id: u.id,
76+
name: getUserDisplayName(u),
77+
}));
78+
6979
if (hsUserId) {
7080
const hsUser = apiUsers.find((u) => u.id === hsUserId);
7181
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
7282
firstMachine = nodes.find((n) => n.user?.id === hsUserId);
73-
} else {
83+
} else if (headscaleOidcEnabled) {
7484
const matched = findHeadscaleUserBySubject(
7585
apiUsers,
7686
principal.user.subject,
@@ -98,6 +108,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
98108

99109
return {
100110
firstMachine,
111+
headscaleOidcEnabled,
101112
headscaleUsers,
102113
linkedUserName,
103114
needsUserLink,
@@ -112,17 +123,110 @@ export async function loader({ request, context }: Route.LoaderArgs) {
112123
};
113124
}
114125

126+
export async function action({ request, context }: Route.ActionArgs) {
127+
const principal = await context.auth.require(request);
128+
if (principal.kind !== "oidc") {
129+
throw data({ error: "Onboarding actions require OIDC authentication" }, { status: 403 });
130+
}
131+
132+
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
133+
const api = context.hsApi.getRuntimeClient(apiKey);
134+
const formData = await request.formData();
135+
const intent = formData.get("intent");
136+
137+
if (intent === "register-node") {
138+
const nodeKey = formData.get("nodeKey");
139+
const userId = formData.get("userId");
140+
141+
if (!nodeKey || typeof nodeKey !== "string") {
142+
return data({ error: "Node key is required" }, { status: 400 });
143+
}
144+
145+
if (!userId || typeof userId !== "string") {
146+
return data({ error: "User is required" }, { status: 400 });
147+
}
148+
149+
try {
150+
const machine = await api.registerNode(userId, nodeKey);
151+
return { success: true, machine };
152+
} catch (e) {
153+
log.error("api", "Failed to register node: %o", e);
154+
return data(
155+
{ error: "Failed to register node. Check that the node key is valid." },
156+
{ status: 500 },
157+
);
158+
}
159+
}
160+
161+
if (intent === "create-user") {
162+
const username = formData.get("username");
163+
164+
if (!username || typeof username !== "string") {
165+
return data({ error: "Username is required" }, { status: 400 });
166+
}
167+
168+
try {
169+
const user = await api.createUser(
170+
username,
171+
principal.profile.email,
172+
principal.profile.name,
173+
principal.profile.picture,
174+
);
175+
return { success: true, user };
176+
} catch (e) {
177+
log.error("api", "Failed to create user: %o", e);
178+
return data(
179+
{ error: "Failed to create user. The username may already exist." },
180+
{ status: 500 },
181+
);
182+
}
183+
}
184+
185+
return data({ error: "Invalid intent" }, { status: 400 });
186+
}
187+
115188
export default function Page({
116-
loaderData: { user, osValue, firstMachine, needsUserLink, linkedUserName, headscaleUsers },
189+
loaderData: {
190+
user,
191+
osValue,
192+
firstMachine,
193+
headscaleOidcEnabled,
194+
headscaleUsers,
195+
linkedUserName,
196+
needsUserLink,
197+
},
117198
}: Route.ComponentProps) {
118199
const { pause, resume } = useLiveData();
200+
const fetcher = useFetcher();
201+
const [showNodeKeyDialog, setShowNodeKeyDialog] = useState(false);
202+
const [showCreateUserDialog, setShowCreateUserDialog] = useState(false);
203+
const [nodeKey, setNodeKey] = useState("");
204+
const [selectedUserId, setSelectedUserId] = useState("");
205+
const [newUsername, setNewUsername] = useState("");
206+
119207
useEffect(() => {
120208
if (firstMachine) {
121209
pause();
122-
} else {
210+
} else if (headscaleOidcEnabled) {
123211
resume();
124212
}
125-
}, [firstMachine]);
213+
}, [firstMachine, headscaleOidcEnabled]);
214+
215+
useEffect(() => {
216+
if (fetcher.data?.success) {
217+
if (fetcher.data.machine) {
218+
toast("Device registered successfully!");
219+
setShowNodeKeyDialog(false);
220+
setNodeKey("");
221+
setSelectedUserId("");
222+
}
223+
if (fetcher.data.user) {
224+
toast("User created successfully!");
225+
setShowCreateUserDialog(false);
226+
setNewUsername("");
227+
}
228+
}
229+
}, [fetcher.data]);
126230

127231
const subject = user.email ? (
128232
<>
@@ -132,6 +236,8 @@ export default function Page({
132236
"with your OIDC provider"
133237
);
134238

239+
const isSubmitting = fetcher.state === "submitting";
240+
135241
return (
136242
<div className="fixed flex h-screen w-full items-center px-4">
137243
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
@@ -192,8 +298,12 @@ export default function Page({
192298
Let's get set up
193299
</Card.Title>
194300
<Card.Text>
195-
Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
196-
automatically added to your Headscale network.
301+
Install Tailscale and sign in{" "}
302+
{headscaleOidcEnabled ? subject : "with your Headscale user"}. Once you sign in on a
303+
device, it will be
304+
{headscaleOidcEnabled
305+
? " automatically added to your Headscale network."
306+
: " ready to connect."}
197307
</Card.Text>
198308

199309
<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
@@ -360,7 +470,7 @@ export default function Page({
360470
</Button>
361471
</NavLink>
362472
</div>
363-
) : (
473+
) : headscaleOidcEnabled ? (
364474
<div className="flex h-full flex-col items-center justify-center gap-4">
365475
<span className="relative flex size-4">
366476
<span
@@ -373,6 +483,45 @@ export default function Page({
373483
<span className={cn("relative inline-flex size-4 rounded-full", "bg-mist-400")} />
374484
</span>
375485
<p className="font-lg">Waiting for your first device...</p>
486+
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
487+
Or use the option below
488+
</p>
489+
<div className="mt-4 flex w-full flex-col gap-2">
490+
<Button
491+
className="flex w-full items-center justify-center gap-2"
492+
variant="light"
493+
onPress={() => setShowNodeKeyDialog(true)}
494+
>
495+
<Key className="size-4" />
496+
Register with Node Key
497+
</Button>
498+
</div>
499+
</div>
500+
) : (
501+
<div className="flex h-full flex-col items-center justify-center gap-4">
502+
<Card.Title className="text-center">Connect Your Device</Card.Title>
503+
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
504+
Since Headscale is not using OIDC, you can register devices manually or create a
505+
Headscale user.
506+
</p>
507+
<div className="mt-4 flex w-full flex-col gap-2">
508+
<Button
509+
className="flex w-full items-center justify-center gap-2"
510+
variant="heavy"
511+
onPress={() => setShowNodeKeyDialog(true)}
512+
>
513+
<Key className="size-4" />
514+
Register with Node Key
515+
</Button>
516+
<Button
517+
className="flex w-full items-center justify-center gap-2"
518+
variant="light"
519+
onPress={() => setShowCreateUserDialog(true)}
520+
>
521+
<UserPlus className="size-4" />
522+
Create Headscale User
523+
</Button>
524+
</div>
376525
</div>
377526
)}
378527
</Card>
@@ -383,6 +532,90 @@ export default function Page({
383532
</Button>
384533
</NavLink>
385534
</div>
535+
536+
<Dialog isOpen={showNodeKeyDialog} onOpenChange={setShowNodeKeyDialog}>
537+
<Dialog.Panel>
538+
<Dialog.Title>Register Device with Node Key</Dialog.Title>
539+
<Dialog.Text>
540+
Enter the node key from your Tailscale client to register it with Headscale. You can get
541+
this by running{" "}
542+
<code className="rounded bg-mist-100 px-1 dark:bg-mist-800">
543+
tailscale debug nodekey
544+
</code>
545+
.
546+
</Dialog.Text>
547+
<fetcher.Form method="POST" className="mt-4 flex flex-col gap-4">
548+
<input type="hidden" name="intent" value="register-node" />
549+
<Input
550+
label="Node Key"
551+
name="nodeKey"
552+
placeholder="nodekey:..."
553+
value={nodeKey}
554+
onChange={(v) => setNodeKey(v)}
555+
isRequired
556+
/>
557+
<div className="flex flex-col gap-1">
558+
<label className="text-sm font-medium">Assign to User</label>
559+
<select
560+
name="userId"
561+
value={selectedUserId}
562+
onChange={(e) => setSelectedUserId(e.target.value)}
563+
className={cn(
564+
"rounded-lg border px-3 py-2",
565+
"border-mist-200 dark:border-mist-700",
566+
"bg-mist-50 dark:bg-mist-900",
567+
)}
568+
required
569+
>
570+
<option value="">Select a user...</option>
571+
{headscaleUsers.map((u) => (
572+
<option key={u.id} value={u.id}>
573+
{u.name}
574+
</option>
575+
))}
576+
</select>
577+
</div>
578+
{fetcher.data?.error && <Notice variant="error">{fetcher.data.error}</Notice>}
579+
<div className="mt-2 flex justify-end gap-2">
580+
<Button variant="light" onPress={() => setShowNodeKeyDialog(false)}>
581+
Cancel
582+
</Button>
583+
<Button type="submit" variant="heavy" isDisabled={isSubmitting}>
584+
{isSubmitting ? "Registering..." : "Register Device"}
585+
</Button>
586+
</div>
587+
</fetcher.Form>
588+
</Dialog.Panel>
589+
</Dialog>
590+
591+
<Dialog isOpen={showCreateUserDialog} onOpenChange={setShowCreateUserDialog}>
592+
<Dialog.Panel>
593+
<Dialog.Title>Create Headscale User</Dialog.Title>
594+
<Dialog.Text>
595+
Create a new Headscale user that you can use to register devices.
596+
</Dialog.Text>
597+
<fetcher.Form method="POST" className="mt-4 flex flex-col gap-4">
598+
<input type="hidden" name="intent" value="create-user" />
599+
<Input
600+
label="Username"
601+
name="username"
602+
placeholder="Enter a username"
603+
value={newUsername}
604+
onChange={(v) => setNewUsername(v)}
605+
isRequired
606+
/>
607+
{fetcher.data?.error && <Notice variant="error">{fetcher.data.error}</Notice>}
608+
<div className="mt-2 flex justify-end gap-2">
609+
<Button variant="light" onPress={() => setShowCreateUserDialog(false)}>
610+
Cancel
611+
</Button>
612+
<Button type="submit" variant="heavy" isDisabled={isSubmitting}>
613+
{isSubmitting ? "Creating..." : "Create User"}
614+
</Button>
615+
</div>
616+
</fetcher.Form>
617+
</Dialog.Panel>
618+
</Dialog>
386619
</div>
387620
);
388621
}

0 commit comments

Comments
 (0)