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 {
|
||||
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"
|
||||
|
||||
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);
|
||||
|
||||
// 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')!);
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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'),
|
||||
|
||||
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 { 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;
|
||||
}[];
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user