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 {
configAvailable: boolean;
uiAccess: boolean;
onboarding: boolean;
user?: AuthSession['user'];
}
@ -136,7 +137,7 @@ export default function Header(data: Props) {
) : undefined}
</div>
</div>
{data.uiAccess ? (
{data.uiAccess && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
<TabLink
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);
// 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) {
try {
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 {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
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
// 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);
return {
config: context.hs.c,
@ -36,6 +88,7 @@ export async function loader({
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
// No session, so we can just return
@ -54,13 +107,22 @@ export default function Shell() {
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Access Denied</Card.Title>
<BanIcon className="w-10 h-10" />
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="mt-4 text-lg">
Your account does not have access to the UI. Please contact your
administrator.
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</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>
)}
<Footer {...data} />

View File

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

View File

@ -14,6 +14,7 @@ export default [
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
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 { resolve } from 'node:path';
import { exit } from 'node:process';
import {
CookieSerializeOptions,
@ -198,22 +199,26 @@ export async function createSessionStorage(
}
async function loadUserFile(path: string) {
const realPath = resolve(path);
try {
const handle = await open(path, 'w');
log.info('config', 'Using user database file at %s', path);
const handle = await open(realPath, 'r+');
log.info('config', 'Using user database file at %s', realPath);
await handle.close();
} 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);
exit(1);
}
try {
const data = await readFile(path, 'utf8');
const users = JSON.parse(data) as { u?: string; c?: number }[];
const data = await readFile(realPath, 'utf8');
const users = JSON.parse(data.trim()) as { u?: string; c?: number }[];
// 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;
c: number;
}[];

View File

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

View File

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