diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index dbbedd62..252c33e3 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -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"; @@ -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 ? ( <> @@ -104,6 +200,8 @@ export default function Page({ "with your OIDC provider" ); + const isSubmitting = fetcher.state === "submitting"; + return (
Waiting for your first device...
++ Or use one of the options below +
++ Since Headscale is not using OIDC, you can register devices manually or create a + Headscale user. +
+