Skip to content

Commit b7aba04

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 b7aba04

File tree

1 file changed

+245
-10
lines changed

1 file changed

+245
-10
lines changed

app/routes/users/onboarding.tsx

Lines changed: 245 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 [nodeKeyDialogOpen, setNodeKeyDialogOpen] = useState(false);
202+
const [createUserDialogOpen, setCreateUserDialogOpen] = 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+
setNodeKeyDialogOpen(false);
220+
setNodeKey("");
221+
setSelectedUserId("");
222+
}
223+
if (fetcher.data.user) {
224+
toast("User created successfully!");
225+
setCreateUserDialogOpen(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,14 @@ 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+
{headscaleOidcEnabled ? (
302+
<>
303+
Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
304+
automatically added to your Headscale network.
305+
</>
306+
) : (
307+
"Install Tailscale and sign in with your Headscale user. Once you sign in on a device, it will be ready to connect."
308+
)}
197309
</Card.Text>
198310

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

0 commit comments

Comments
 (0)