feat: implement onboarding for non-registered users
This commit is contained in:
parent
17d477bf0f
commit
80c987f383
@ -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"
|
||||||
|
|||||||
78
app/components/Options.tsx
Normal file
78
app/components/Options.tsx
Normal 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 });
|
||||||
@ -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')!);
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
341
app/routes/users/onboarding.tsx
Normal file
341
app/routes/users/onboarding.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}[];
|
}[];
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user