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 [ 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