11import { 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
66import Button from "~/components/Button" ;
77import Card from "~/components/Card" ;
8+ import Dialog from "~/components/Dialog" ;
9+ import Input from "~/components/Input" ;
810import Link from "~/components/link" ;
11+ import Notice from "~/components/Notice" ;
912import Options from "~/components/Options" ;
1013import StatusCircle from "~/components/StatusCircle" ;
1114import { 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+
115188export 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