chore: switch to react-router v7

This commit is contained in:
Aarnav Tale 2024-12-31 10:30:14 +05:30
parent 39504e2487
commit aa9872a45b
No known key found for this signature in database
101 changed files with 3825 additions and 6796 deletions

View file

@ -1,12 +1,12 @@
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card'
import Link from '~/components/Link'
import Card from '~/components/Card';
import Link from '~/components/Link';
import Add from '../dialogs/add'
import Add from '../dialogs/add';
interface Props {
readonly magic: string | undefined
readonly magic: string | undefined;
}
export default function Auth({ magic }: Props) {
@ -15,14 +15,10 @@ export default function Auth({ magic }: Props) {
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<HomeIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
Basic Authentication
</h2>
<h2 className="font-medium mb-1">Basic Authentication</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
Users are not managed externally.
Using OpenID Connect can create a better
experience when using Headscale.
{' '}
Users are not managed externally. Using OpenID Connect can create a
better experience when using Headscale.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
@ -33,9 +29,7 @@ export default function Auth({ magic }: Props) {
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can add, remove, and rename users here.
</p>
@ -45,5 +39,5 @@ export default function Auth({ magic }: Props) {
</div>
</div>
</Card>
)
);
}

View file

@ -1,14 +1,14 @@
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card'
import Link from '~/components/Link'
import { HeadplaneContext } from '~/utils/config/headplane'
import Card from '~/components/Card';
import Link from '~/components/Link';
import { HeadplaneContext } from '~/utils/config/headplane';
import Add from '../dialogs/add'
import Add from '../dialogs/add';
interface Props {
readonly oidc: NonNullable<HeadplaneContext['oidc']>
readonly magic: string | undefined
readonly oidc: NonNullable<HeadplaneContext['oidc']>;
readonly magic: string | undefined;
}
export default function Oidc({ oidc, magic }: Props) {
@ -17,18 +17,14 @@ export default function Oidc({ oidc, magic }: Props) {
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
<OrganizationIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
OpenID Connect
</h2>
<h2 className="font-medium mb-1">OpenID Connect</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
Users are managed through your
{' '}
Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider">
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.
{' '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
@ -39,12 +35,10 @@ export default function Oidc({ oidc, magic }: Props) {
</div>
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">
User Management
</h2>
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-ui-600 dark:text-ui-300">
You can still add users manually, however it is recommended
that you manage users through your OIDC provider.
You can still add users manually, however it is recommended that you
manage users through your OIDC provider.
</p>
<div className="flex items-center gap-2 mt-4">
<Add magic={magic} />
@ -52,5 +46,5 @@ export default function Oidc({ oidc, magic }: Props) {
</div>
</div>
</Card>
)
);
}

View file

@ -1,53 +1,39 @@
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
interface Props {
magic?: string
magic?: string;
}
export default function Add({ magic }: Props) {
const [username, setUsername] = useState('')
const submit = useSubmit()
const [username, setUsername] = useState('');
const submit = useSubmit();
return (
<Dialog>
<Dialog.Button>
Add a new user
</Dialog.Button>
<Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Panel>
{close => (
{(close) => (
<>
<Dialog.Title>
Add a new user
</Dialog.Title>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a username to create a new user.
{' '}
{magic
? (
<>
Since Magic DNS is enabled, machines will be
accessible via
{' '}
<Code>
[machine].
.
{magic}
</Code>
.
</>
)
: undefined}
Enter a username to create a new user.{' '}
{magic ? (
<>
Since Magic DNS is enabled, machines will be accessible via{' '}
<Code>[machine]. .{magic}</Code>.
</>
) : undefined}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="create" />
@ -59,16 +45,10 @@ export default function Add({ magic }: Props) {
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Create
</Dialog.Action>
</div>
@ -77,5 +57,5 @@ export default function Add({ magic }: Props) {
)}
</Dialog.Panel>
</Dialog>
)
);
}

View file

@ -1,18 +1,18 @@
import { XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Button from '~/components/Button';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
interface Props {
username: string
username: string;
}
export default function Remove({ username }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
const submit = useSubmit();
const dialogState = useState(false);
return (
<>
@ -25,41 +25,26 @@ export default function Remove({ username }: Props) {
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
{(close) => (
<>
<Dialog.Title>
Delete
{' '}
{username}
?
</Dialog.Title>
<Dialog.Title>Delete {username}?</Dialog.Title>
<Dialog.Text className="mb-8">
Are you sure you want to delete
{' '}
{username}
?
{' '}
A deleted user cannot be recovered.
Are you sure you want to delete {username}? A deleted user
cannot be recovered.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="username" value={username} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Delete
</Dialog.Action>
</div>
@ -69,5 +54,5 @@ export default function Remove({ username }: Props) {
</Dialog.Panel>
</Dialog>
</>
)
);
}

View file

@ -1,20 +1,20 @@
import { PencilIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { PencilIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Button from '~/components/Button'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Button from '~/components/Button';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
interface Props {
username: string
magic?: string
username: string;
magic?: string;
}
export default function Rename({ username, magic }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
const [newName, setNewName] = useState(username)
const submit = useSubmit();
const dialogState = useState(false);
const [newName, setNewName] = useState(username);
return (
<>
@ -27,23 +27,16 @@ export default function Rename({ username, magic }: Props) {
</Button>
<Dialog control={dialogState}>
<Dialog.Panel control={dialogState}>
{close => (
{(close) => (
<>
<Dialog.Title>
Rename
{' '}
{username}
?
</Dialog.Title>
<Dialog.Title>Rename {username}?</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a new username for
{' '}
{username}
Enter a new username for {username}
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget)
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" />
@ -56,16 +49,10 @@ export default function Rename({ username, magic }: Props) {
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
<Dialog.Action variant="confirm" onPress={close}>
Rename
</Dialog.Action>
</div>
@ -75,5 +62,5 @@ export default function Rename({ username, magic }: Props) {
</Dialog.Panel>
</Dialog>
</>
)
);
}

View file

@ -1,48 +1,48 @@
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react';
import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useActionData, useLoaderData, useSubmit } from 'react-router';
import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { send } from '~/utils/res'
import Attribute from '~/components/Attribute';
import Card from '~/components/Card';
import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
import { Machine, User } from '~/types';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { del, post, pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions';
import { useLiveData } from '~/utils/useLiveData';
import { send } from '~/utils/res';
import Auth from './components/auth'
import Oidc from './components/oidc'
import Remove from './dialogs/remove'
import Rename from './dialogs/rename'
import Auth from './components/auth';
import Oidc from './components/oidc';
import Remove from './dialogs/remove';
import Rename from './dialogs/rename';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
]);
const users = apiUsers.users.map(user => ({
const users = apiUsers.users.map((user) => ({
...user,
machines: machines.nodes.filter(machine => machine.user.id === user.id),
}))
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
const context = await loadContext()
let magic: string | undefined
const context = await loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig()
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain
magic = config.dns.base_domain;
}
}
@ -50,125 +50,115 @@ export async function loader({ request }: LoaderFunctionArgs) {
oidc: context.oidc,
magic,
users,
}
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, 401)
return send({ message: 'Unauthorized' }, 401);
}
const data = await request.formData()
const data = await request.formData();
if (!data.has('_method')) {
return send({ message: 'No method provided' }, 400)
return send({ message: 'No method provided' }, 400);
}
const method = String(data.get('_method'))
const method = String(data.get('_method'));
switch (method) {
case 'create': {
if (!data.has('username')) {
return send({ message: 'No name provided' }, 400)
return send({ message: 'No name provided' }, 400);
}
const username = String(data.get('username'))
const username = String(data.get('username'));
await post('v1/user', session.get('hsApiKey')!, {
name: username,
})
});
return { message: `User ${username} created` }
return { message: `User ${username} created` };
}
case 'delete': {
if (!data.has('username')) {
return send({ message: 'No name provided' }, 400)
return send({ message: 'No name provided' }, 400);
}
const username = String(data.get('username'))
await del(`v1/user/${username}`, session.get('hsApiKey')!)
return { message: `User ${username} deleted` }
const username = String(data.get('username'));
await del(`v1/user/${username}`, session.get('hsApiKey')!);
return { message: `User ${username} deleted` };
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
return send({ message: 'No old or new name provided' }, 400)
return send({ message: 'No old or new name provided' }, 400);
}
const old = String(data.get('old'))
const newName = String(data.get('new'))
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
return { message: `User ${old} renamed to ${newName}` }
const old = String(data.get('old'));
const newName = String(data.get('new'));
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!);
return { message: `User ${old} renamed to ${newName}` };
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
return send({ message: 'No ID or destination provided' }, 400)
return send({ message: 'No ID or destination provided' }, 400);
}
const id = String(data.get('id'))
const to = String(data.get('to'))
const name = String(data.get('name'))
const id = String(data.get('id'));
const to = String(data.get('to'));
const name = String(data.get('name'));
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return { message: `Moved ${name} to ${to}` }
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
return { message: `Moved ${name} to ${to}` };
} catch {
return send({ message: `Failed to move ${name} to ${to}` }, 500)
return send({ message: `Failed to move ${name} to ${to}` }, 500);
}
}
default: {
return send({ message: 'Invalid method' }, 400)
return send({ message: 'Invalid method' }, 400);
}
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [users, setUsers] = useState(data.users)
const actionData = useActionData<typeof action>()
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState(data.users);
const actionData = useActionData<typeof action>();
useLiveData({ interval: 3000 });
useEffect(() => {
if (!actionData) {
return
return;
}
toast(actionData.message)
toast(actionData.message);
if (actionData.message.startsWith('Failed')) {
setUsers(data.users)
setUsers(data.users);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData]);
useEffect(() => {
setUsers(data.users)
}, [data.users])
setUsers(data.users);
}, [data.users]);
return (
<>
<h1 className="text-2xl font-medium mb-1.5">
Users
</h1>
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership.
Manage the users in your network and their permissions. Tip: You can
drag machines between users to change ownership.
</p>
{data.oidc
? (
<Oidc
oidc={data.oidc}
magic={data.magic}
/>
)
: (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
<Users users={users} />
}
>
{data.oidc ? (
<Oidc oidc={data.oidc} magic={data.magic} />
) : (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={<Users users={users} />}>
{() => (
<InteractiveUsers
users={users}
@ -178,92 +168,86 @@ export default function Page() {
)}
</ClientOnly>
</>
)
);
}
type UserMachine = User & { machines: Machine[] }
type UserMachine = User & { machines: Machine[] };
interface UserProps {
users: UserMachine[]
setUsers?: (users: UserMachine[]) => void
magic?: string
users: UserMachine[];
setUsers?: (users: UserMachine[]) => void;
magic?: string;
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
)
);
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit()
const submit = useSubmit();
return (
<DndContext onDragEnd={(event) => {
const { over, active } = event
if (!over) {
return
}
<DndContext
onDragEnd={(event) => {
const { over, active } = event;
if (!over) {
return;
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>()
const reference = active.data as DataRef<Machine>
if (!reference.current) {
return
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>();
const reference = active.data as DataRef<Machine>;
if (!reference.current) {
return;
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return;
}
for (const user of users) {
newUsers.push({
...user,
machines: over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter(m => m.id !== active.id),
})
}
for (const user of users) {
newUsers.push({
...user,
machines:
over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter((m) => m.id !== active.id),
});
}
setUsers?.(newUsers)
const data = new FormData()
data.append('_method', 'move')
data.append('id', active.id.toString())
data.append('to', over.id.toString())
data.append('name', reference.current.givenName)
setUsers?.(newUsers);
const data = new FormData();
data.append('_method', 'move');
data.append('id', active.id.toString());
data.append('to', over.id.toString());
data.append('name', reference.current.givenName);
submit(data, {
method: 'POST',
})
}}
submit(data, {
method: 'POST',
});
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map(user => (
<UserCard
key={user.id}
user={user}
magic={magic}
/>
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
</DndContext>
)
);
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
})
});
return (
<div
@ -287,18 +271,18 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
value={machine.ipAddresses[0]}
/>
</div>
)
);
}
interface CardProps {
user: UserMachine
magic?: string
user: UserMachine;
magic?: string;
}
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
})
});
return (
<div ref={setNodeRef}>
@ -312,25 +296,21 @@ function UserCard({ user, magic }: CardProps) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono">
{user.name}
</span>
<span className="text-lg font-mono">{user.name}</span>
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
{user.machines.length === 0
? (
<Remove username={user.name} />
)
: undefined}
{user.machines.length === 0 ? (
<Remove username={user.name} />
) : undefined}
</div>
</div>
<div className="mt-4">
{user.machines.map(machine => (
{user.machines.map((machine) => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
)
);
}