feat: implement onboarding for non-registered users

This commit is contained in:
Aarnav Tale 2025-04-02 13:26:58 -04:00
parent 17d477bf0f
commit 80c987f383
No known key found for this signature in database
10 changed files with 522 additions and 14 deletions

View File

@ -16,6 +16,7 @@ import cn from '~/utils/cn';
interface Props { interface Props {
configAvailable: boolean; configAvailable: boolean;
uiAccess: boolean; uiAccess: boolean;
onboarding: boolean;
user?: AuthSession['user']; user?: AuthSession['user'];
} }
@ -136,7 +137,7 @@ export default function Header(data: Props) {
) : undefined} ) : undefined}
</div> </div>
</div> </div>
{data.uiAccess ? ( {data.uiAccess && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold"> <nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
<TabLink <TabLink
to="/machines" to="/machines"

View File

@ -0,0 +1,78 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });

View File

@ -14,6 +14,7 @@ export async function loader({
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
// We shouldn't session invalidate if Headscale is down // We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) { if (healthy) {
try { try {
await context.client.get('v1/apikey', session.get('api_key')!); await context.client.get('v1/apikey', session.get('api_key')!);

View File

@ -1,15 +1,19 @@
import { BanIcon } from 'lucide-react'; import { BanIcon, CircleCheckIcon } from 'lucide-react';
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
Outlet, Outlet,
redirect, redirect,
useLoaderData, useLoaderData,
} from 'react-router'; } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
import Code from '~/components/Code';
import Footer from '~/components/Footer'; import Footer from '~/components/Footer';
import Header from '~/components/Header'; import Header from '~/components/Header';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
// This loads the bare minimum for the application to function // This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops? // So we know that if context fails to load then well, oops?
@ -28,6 +32,54 @@ export async function loader({
}); });
} }
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = true;
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.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 = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
if (!onboarded) {
return redirect('/onboarding');
}
}
const check = await context.sessions.check(request, Capabilities.ui_access); const check = await context.sessions.check(request, Capabilities.ui_access);
return { return {
config: context.hs.c, config: context.hs.c,
@ -36,6 +88,7 @@ export async function loader({
debug: context.config.debug, debug: context.config.debug,
user: session.get('user'), user: session.get('user'),
uiAccess: check, uiAccess: check,
onboarding: request.url.endsWith('/onboarding'),
}; };
} catch { } catch {
// No session, so we can just return // No session, so we can just return
@ -54,13 +107,22 @@ export default function Shell() {
) : ( ) : (
<Card className="mx-auto w-fit mt-24"> <Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Access Denied</Card.Title> <Card.Title className="text-3xl mb-0">Connected</Card.Title>
<BanIcon className="w-10 h-10" /> <CircleCheckIcon className="w-10 h-10" />
</div> </div>
<Card.Text className="mt-4 text-lg"> <Card.Text className="my-4 text-lg">
Your account does not have access to the UI. Please contact your Connect to Tailscale with your devices to access this Tailnet. Use
administrator. this command to help you get started:
</Card.Text> </Card.Text>
<Button className="pointer-events-none text-md hover:bg-initial focus:ring-0">
<Code className="pointer-events-auto bg-transparent" isCopyable>
tailscale up --login-server={data.url}
</Code>
</Button>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card> </Card>
)} )}
<Footer {...data} /> <Footer {...data} />

View File

@ -1,4 +1,8 @@
import type { LinksFunction, MetaFunction } from 'react-router'; import type {
LinksFunction,
LoaderFunctionArgs,
MetaFunction,
} from 'react-router';
import { import {
Links, Links,
Meta, Meta,
@ -14,6 +18,8 @@ import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url'; import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data'; import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast'; import { useToastQueue } from '~/utils/toast';
import { LoadContext } from './server';
import log from './utils/log';
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { title: 'Headplane' },

View File

@ -14,6 +14,7 @@ export default [
// All the main logged-in dashboard routes // All the main logged-in dashboard routes
// Double nested to separate error propagations // Double nested to separate error propagations
layout('layouts/shell.tsx', [ layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
layout('layouts/dashboard.tsx', [ layout('layouts/dashboard.tsx', [
...prefix('/machines', [ ...prefix('/machines', [
index('routes/machines/overview.tsx'), index('routes/machines/overview.tsx'),

View File

@ -0,0 +1,341 @@
import { useEffect } from 'react';
import { GrApple } from 'react-icons/gr';
import { ImFinder } from 'react-icons/im';
import { MdAndroid } from 'react-icons/md';
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
import {
LoaderFunctionArgs,
NavLink,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Options from '~/components/Options';
import StatusCircle from '~/components/StatusCircle';
import { LoadContext } from '~/server';
import { Machine } from '~/types';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
return redirect('/login');
}
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
// We need to convert this to a known value to return it to the client so we can
// automatically tab to the correct download button.
const userAgent = request.headers.get('user-agent');
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = 'linux';
switch (os?.[0]) {
case 'Windows':
osValue = 'windows';
break;
case 'Mac OS X':
osValue = 'macos';
break;
case 'iPhone':
case 'iPad':
osValue = 'ios';
break;
case 'Android':
osValue = 'android';
break;
default:
osValue = 'linux';
break;
}
let firstMachine: Machine | undefined = undefined;
try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
);
const node = nodes.find((n) => {
if (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;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (subject !== sessionUser.subject) {
return false;
}
return true;
});
firstMachine = node;
} catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug('api', 'Failed to lookup nodes %o', e);
}
return {
user,
osValue,
firstMachine,
};
}
export default function Page() {
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
pause();
} else {
resume();
}
}, [firstMachine]);
const subject = user.email ? (
<>
as <strong>{user.email}</strong>
</>
) : (
'with your OIDC provider'
);
return (
<div className="fixed w-full h-screen flex items-center px-4">
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
<Card variant="flat" className="max-w-lg">
<Card.Title className="mb-8">
Welcome!
<br />
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.
</Card.Text>
<Options
defaultSelectedKey={osValue}
label="Download Selector"
className="my-4"
>
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<PiTerminalFill className="ml-1 w-4" />
<span>Linux</span>
</div>
}
>
<Button
variant="heavy"
className={cn(
'my-4 px-0 w-full pointer-events-none',
'hover:bg-initial focus:ring-0',
)}
>
<Code
isCopyable
className="bg-transparent pointer-events-auto mx-0"
>
curl -fsSL https://tailscale.com/install.sh | sh
</Code>
</Button>
<p className="text-end text-sm">
<Link
name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</Options.Item>
<Options.Item
key="windows"
title={
<div className="flex items-center gap-1">
<PiWindowsLogoFill className="ml-1 w-4" />
<span>Windows</span>
</div>
}
>
<a
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
aria-label="Download for Windows"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for Windows
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<ImFinder className="ml-1 w-4" />
<span>macOS</span>
</div>
}
>
<a
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
aria-label="Download for macOS"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for macOS
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{' '}
<Link
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
macOS App Store
</Link>
{'.'}
</p>
</Options.Item>
<Options.Item
key="ios"
title={
<div className="flex items-center gap-1">
<GrApple className="ml-1 w-4" />
<span>iOS</span>
</div>
}
>
<a
href="https://apps.apple.com/us/app/tailscale/id1470499037"
aria-label="Download for iOS"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for iOS
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<MdAndroid className="ml-1 w-4" />
<span>Android</span>
</div>
}
>
<a
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
aria-label="Download for Android"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for Android
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card variant="flat">
{firstMachine ? (
<div className="flex flex-col justify-between h-full">
<Card.Title className="mb-8">
Success!
<br />
We found your first device
</Card.Title>
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
<div className="flex items-start gap-4">
<StatusCircle
isOnline={firstMachine.online}
className="size-6 mt-3"
/>
<div>
<p className="font-semibold leading-snug">
{firstMachine.givenName}
</p>
<p className="text-sm font-mono opacity-50">
{firstMachine.name}
</p>
<div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => (
<p key={ip} className="text-xs font-mono opacity-50">
{ip}
</p>
))}
</div>
</div>
</div>
</div>
<NavLink to="/">
<Button variant="heavy" className="w-full">
Continue
</Button>
</NavLink>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 h-full">
<span className="relative flex size-4">
<span
className={cn(
'absolute inline-flex h-full w-full',
'rounded-full opacity-75 animate-ping',
'bg-headplane-500',
)}
/>
<span
className={cn(
'relative inline-flex size-4 rounded-full',
'bg-headplane-400',
)}
/>
</span>
<p className="font-lg">Waiting for your first device...</p>
</div>
)}
</Card>
</div>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { open, readFile } from 'node:fs/promises'; import { open, readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { exit } from 'node:process'; import { exit } from 'node:process';
import { import {
CookieSerializeOptions, CookieSerializeOptions,
@ -198,22 +199,26 @@ export async function createSessionStorage(
} }
async function loadUserFile(path: string) { async function loadUserFile(path: string) {
const realPath = resolve(path);
try { try {
const handle = await open(path, 'w'); const handle = await open(realPath, 'r+');
log.info('config', 'Using user database file at %s', path); log.info('config', 'Using user database file at %s', realPath);
await handle.close(); await handle.close();
} catch (error) { } catch (error) {
log.info('config', 'User database file not accessible at %s', path); log.info('config', 'User database file not accessible at %s', realPath);
log.debug('config', 'Error details: %s', error); log.debug('config', 'Error details: %s', error);
exit(1); exit(1);
} }
try { try {
const data = await readFile(path, 'utf8'); const data = await readFile(realPath, 'utf8');
const users = JSON.parse(data) as { u?: string; c?: number }[]; const users = JSON.parse(data.trim()) as { u?: string; c?: number }[];
// Never trust user input // Never trust user input
return users.filter((user) => user.u && user.c) as { return users.filter(
(user) => user.u !== undefined && user.c !== undefined,
) as {
u: string; u: string;
c: number; c: number;
}[]; }[];

View File

@ -40,6 +40,7 @@
"react-codemirror-merge": "^4.23.7", "react-codemirror-merge": "^4.23.7",
"react-dom": "19.0.0", "react-dom": "19.0.0",
"react-error-boundary": "^5.0.0", "react-error-boundary": "^5.0.0",
"react-icons": "^5.5.0",
"react-router": "^7.4.0", "react-router": "^7.4.0",
"react-router-hono-server": "^2.11.0", "react-router-hono-server": "^2.11.0",
"react-stately": "^3.35.0", "react-stately": "^3.35.0",

View File

@ -103,6 +103,9 @@ importers:
react-error-boundary: react-error-boundary:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(react@19.0.0) version: 5.0.0(react@19.0.0)
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.0.0)
react-router: react-router:
specifier: ^7.4.0 specifier: ^7.4.0
version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -2736,6 +2739,11 @@ packages:
peerDependencies: peerDependencies:
react: '>=16.13.1' react: '>=16.13.1'
react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies:
react: '*'
react-refresh@0.14.2: react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -6235,6 +6243,10 @@ snapshots:
'@babel/runtime': 7.26.0 '@babel/runtime': 7.26.0
react: 19.0.0 react: 19.0.0
react-icons@5.5.0(react@19.0.0):
dependencies:
react: 19.0.0
react-refresh@0.14.2: {} react-refresh@0.14.2: {}
react-router-dom@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0): react-router-dom@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):