Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 253 additions & 30 deletions app/routes/users/onboarding.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Icon } from "@iconify/react";
import { ArrowRight } from "lucide-react";
import { useEffect } from "react";
import { NavLink } from "react-router";
import { ArrowRight, Key, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { data, NavLink, useFetcher } from "react-router";

import Button from "~/components/Button";
import Card from "~/components/Card";
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import Link from "~/components/Link";
import Notice from "~/components/Notice";
import Options from "~/components/Options";
import StatusCircle from "~/components/StatusCircle";
import { Machine } from "~/types";
Expand Down Expand Up @@ -47,54 +50,147 @@ export async function loader({ request, context }: Route.LoaderArgs) {
break;
}

// Check if Headscale uses OIDC
const headscaleOidcEnabled = !!context.hs.c?.oidc;

const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined;
try {
const nodes = await api.getNodes();
const node = nodes.find((n) => {
// Tag-only nodes have no user
if (!n.user || n.user.provider !== "oidc") {
return false;
}

// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = n.user.providerId?.split("/").pop();
if (!subject) {
return false;
}
// Only look for OIDC-linked devices if Headscale uses OIDC
if (headscaleOidcEnabled) {
try {
const nodes = await api.getNodes();
const node = nodes.find((n) => {
// Tag-only nodes have no user
if (!n.user || n.user.provider !== "oidc") {
return false;
}

if (subject !== session.user.subject) {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = n.user.providerId?.split("/").pop();
if (!subject) {
return false;
}

if (subject !== session.user.subject) {
return false;
}

return true;
});
return true;
});

firstMachine = node;
} catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug("api", "Failed to lookup nodes %o", e);
}
}

firstMachine = node;
// Get available users for node-key registration
let availableUsers: { id: string; name: string }[] = [];
try {
const users = await api.getUsers();
availableUsers = users.map((u) => ({ id: u.id, name: u.name }));
} catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug("api", "Failed to lookup nodes %o", e);
log.debug("api", "Failed to lookup users %o", e);
}

return {
user: session.user,
osValue,
firstMachine,
headscaleOidcEnabled,
availableUsers,
};
}

export async function action({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
const formData = await request.formData();
const intent = formData.get("intent");

if (intent === "register-node") {
const nodeKey = formData.get("nodeKey");
const userId = formData.get("userId");

if (!nodeKey || typeof nodeKey !== "string") {
throw data({ error: "Node key is required" }, { status: 400 });
}

if (!userId || typeof userId !== "string") {
throw data({ error: "User is required" }, { status: 400 });
}

try {
const machine = await api.registerNode(userId, nodeKey);
return { success: true, machine };
} catch (e) {
log.error("api", "Failed to register node: %o", e);
throw data({ error: "Failed to register node" }, { status: 500 });
}
}

if (intent === "create-user") {
const username = formData.get("username");

if (!username || typeof username !== "string") {
throw data({ error: "Username is required" }, { status: 400 });
}

try {
const user = await api.createUser(
username,
session.user.email ?? undefined,
session.user.name ?? undefined,
session.user.picture ?? undefined,
);
return { success: true, user };
} catch (e) {
log.error("api", "Failed to create user: %o", e);
throw data({ error: "Failed to create user" }, { status: 500 });
}
}

throw data({ error: "Invalid intent" }, { status: 400 });
}

export default function Page({
loaderData: { user, osValue, firstMachine },
loaderData: { user, osValue, firstMachine, headscaleOidcEnabled, availableUsers },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData();
const fetcher = useFetcher();
const [showNodeKeyDialog, setShowNodeKeyDialog] = useState(false);
const [showCreateUserDialog, setShowCreateUserDialog] = useState(false);
const [nodeKey, setNodeKey] = useState("");
const [selectedUserId, setSelectedUserId] = useState("");
const [newUsername, setNewUsername] = useState("");

useEffect(() => {
if (firstMachine) {
pause();
} else {
} else if (headscaleOidcEnabled) {
resume();
}
}, [firstMachine]);
}, [firstMachine, headscaleOidcEnabled]);

// Handle successful actions
useEffect(() => {
if (fetcher.data?.success) {
if (fetcher.data.machine) {
toast("Device registered successfully!");
setShowNodeKeyDialog(false);
setNodeKey("");
setSelectedUserId("");
}
if (fetcher.data.user) {
toast("User created successfully!");
setShowCreateUserDialog(false);
setNewUsername("");
}
}
}, [fetcher.data]);

const subject = user.email ? (
<>
Expand All @@ -104,6 +200,8 @@ export default function Page({
"with your OIDC provider"
);

const isSubmitting = fetcher.state === "submitting";

return (
<div className="fixed flex h-screen w-full items-center px-4">
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
Expand All @@ -114,8 +212,12 @@ export default function Page({
Let's get set up
</Card.Title>
<Card.Text>
Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
automatically added to your Headscale network.
Install Tailscale and sign in{" "}
{headscaleOidcEnabled ? subject : "with your Headscale user"}. Once you sign in on a
device, it will be
{headscaleOidcEnabled
? " automatically added to your Headscale network."
: " ready to connect."}
</Card.Text>

<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
Expand Down Expand Up @@ -284,7 +386,7 @@ export default function Page({
</Button>
</NavLink>
</div>
) : (
) : headscaleOidcEnabled ? (
<div className="flex h-full flex-col items-center justify-center gap-4">
<span className="relative flex size-4">
<span
Expand All @@ -299,6 +401,45 @@ export default function Page({
/>
</span>
<p className="font-lg">Waiting for your first device...</p>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Or use one of the options below
</p>
<div className="mt-4 flex w-full flex-col gap-2">
<Button
className="flex w-full items-center justify-center gap-2"
variant="light"
onPress={() => setShowNodeKeyDialog(true)}
>
<Key className="size-4" />
Register with Node Key
</Button>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-4">
<Card.Title className="text-center">Connect Your Device</Card.Title>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Since Headscale is not using OIDC, you can register devices manually or create a
Headscale user.
</p>
<div className="mt-4 flex w-full flex-col gap-2">
<Button
className="flex w-full items-center justify-center gap-2"
variant="heavy"
onPress={() => setShowNodeKeyDialog(true)}
>
<Key className="size-4" />
Register with Node Key
</Button>
<Button
className="flex w-full items-center justify-center gap-2"
variant="light"
onPress={() => setShowCreateUserDialog(true)}
>
<UserPlus className="size-4" />
Create Headscale User
</Button>
</div>
</div>
)}
</Card>
Expand All @@ -309,6 +450,88 @@ export default function Page({
</Button>
</NavLink>
</div>

{/* Node Key Registration Dialog */}
<Dialog isOpen={showNodeKeyDialog} onOpenChange={setShowNodeKeyDialog}>
<Dialog.Panel>
<Dialog.Title>Register Device with Node Key</Dialog.Title>
<Dialog.Text>
Enter the node key from your Tailscale client to register it with Headscale. You can get
this by running{" "}
<code className="bg-headplane-100 dark:bg-headplane-800 rounded px-1">
tailscale debug nodekey
</code>
.
</Dialog.Text>
<fetcher.Form method="POST" className="mt-4 flex flex-col gap-4">
<input type="hidden" name="intent" value="register-node" />
<Input
label="Node Key"
name="nodeKey"
placeholder="nodekey:..."
value={nodeKey}
onChange={(v) => setNodeKey(v)}
isRequired
/>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium">Assign to User</label>
<select
name="userId"
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className="bg-headplane-50 dark:bg-headplane-900 border-headplane-200 dark:border-headplane-700 rounded-lg border px-3 py-2"
required
>
<option value="">Select a user...</option>
{availableUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
{fetcher.data?.error && <Notice variant="error">{fetcher.data.error}</Notice>}
<div className="mt-2 flex justify-end gap-2">
<Button variant="light" onPress={() => setShowNodeKeyDialog(false)}>
Cancel
</Button>
<Button type="submit" variant="heavy" isDisabled={isSubmitting}>
{isSubmitting ? "Registering..." : "Register Device"}
</Button>
</div>
</fetcher.Form>
</Dialog.Panel>
</Dialog>

{/* Create User Dialog */}
<Dialog isOpen={showCreateUserDialog} onOpenChange={setShowCreateUserDialog}>
<Dialog.Panel>
<Dialog.Title>Create Headscale User</Dialog.Title>
<Dialog.Text>
Create a new Headscale user that you can use to register devices.
</Dialog.Text>
<fetcher.Form method="POST" className="mt-4 flex flex-col gap-4">
<input type="hidden" name="intent" value="create-user" />
<Input
label="Username"
name="username"
placeholder="Enter a username"
value={newUsername}
onChange={(v) => setNewUsername(v)}
isRequired
/>
{fetcher.data?.error && <Notice variant="error">{fetcher.data.error}</Notice>}
<div className="mt-2 flex justify-end gap-2">
<Button variant="light" onPress={() => setShowCreateUserDialog(false)}>
Cancel
</Button>
<Button type="submit" variant="heavy" isDisabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create User"}
</Button>
</div>
</fetcher.Form>
</Dialog.Panel>
</Dialog>
</div>
);
}