fix: use new headscale user api routes

This commit is contained in:
Aarnav Tale 2025-02-19 13:01:20 -05:00
parent e6580eed2c
commit 774fdb7be2
No known key found for this signature in database
12 changed files with 250 additions and 252 deletions

View File

@ -1,43 +0,0 @@
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import Add from '../dialogs/add';
interface Props {
readonly magic: string | undefined;
}
export default function Auth({ magic }: Props) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
<HomeIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">Basic Authentication</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
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"
>
Learn more
</Link>
</p>
</div>
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
You can add, remove, and rename users here.
</p>
<div className="flex items-center gap-2 mt-4">
<Add />
</div>
</div>
</div>
</Card>
);
}

View File

@ -0,0 +1,69 @@
import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import { HeadplaneConfig } from '~/utils/state';
import CreateUser from '../dialogs/create-user';
interface Props {
oidc?: NonNullable<HeadplaneConfig['oidc']>;
}
export default function ManageBanner({ oidc }: Props) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
{oidc ? (
<Building2 className="w-5 h-5 mb-2" />
) : (
<House className="w-5 h-5 mb-2" />
)}
<h2 className="font-medium mb-1">
{oidc ? 'OpenID Connect' : 'User Authentication'}
</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
{oidc ? (
<>
Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider">
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
>
Learn more
</Link>
</>
) : (
<>
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"
>
Learn more
</Link>
</>
)}
</p>
</div>
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
<Key className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
{oidc
? 'You can still add users manually, however it is recommended that you manage users through your OIDC provider.'
: 'You can add, remove, and rename users here.'}
</p>
<div className="flex items-center gap-2 mt-4">
<CreateUser />
</div>
</div>
</div>
</Card>
);
}

View File

@ -1,47 +0,0 @@
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import { HeadplaneConfig } from '~/utils/state';
import Add from '../dialogs/add';
interface Props {
readonly oidc: NonNullable<HeadplaneConfig['oidc']>;
}
export default function Oidc({ oidc }: Props) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
<OrganizationIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">OpenID Connect</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider">
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
>
Learn more
</Link>
</p>
</div>
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
<PasskeyFillIcon className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
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 />
</div>
</div>
</div>
</Card>
);
}

View File

@ -1,25 +0,0 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
export default function Add() {
return (
<Dialog>
<Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a username to create a new user. Usernames can be addressed when
managing ACL policies.
</Dialog.Text>
<input type="hidden" name="_method" value="create" />
<Input
isRequired
name="username"
label="Username"
placeholder="my-new-user"
/>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,33 @@
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
// TODO: Support image upload for user avatars
export default function CreateUser() {
return (
<Dialog>
<Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-6">
Enter a username to create a new user. Usernames can be addressed when
managing ACL policies.
</Dialog.Text>
<input type="hidden" name="action_id" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
name="username"
label="Username"
placeholder="my-new-user"
/>
<Input
name="display_name"
label="Display Name"
placeholder="John Doe"
/>
<Input name="email" label="Email" placeholder="name@example.com" />
</div>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -0,0 +1,30 @@
import { X } from 'lucide-react';
import Dialog from '~/components/Dialog';
import { User } from '~/types';
interface Props {
user: User;
}
// TODO: Warn that OIDC users will be recreated on next login
export default function DeleteUser({ user }: Props) {
const name =
(user.displayName?.length ?? 0) > 0 ? user.displayName : user.name;
return (
<Dialog>
<Dialog.IconButton label={`Delete ${name}`}>
<X className="p-0.5" />
</Dialog.IconButton>
<Dialog.Panel>
<Dialog.Title>Delete {name}?</Dialog.Title>
<Dialog.Text className="mb-6">
Are you sure you want to delete {name}? A deleted user cannot be
recovered.
</Dialog.Text>
<input type="hidden" name="action_id" value="delete_user" />
<input type="hidden" name="user_id" value={user.id} />
</Dialog.Panel>
</Dialog>
);
}

View File

@ -1,26 +0,0 @@
import { X } from 'lucide-react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
interface Props {
username: string;
}
export default function Remove({ username }: Props) {
return (
<Dialog>
<Dialog.IconButton label={`Delete ${username}`}>
<X className="p-0.5" />
</Dialog.IconButton>
<Dialog.Panel>
<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.
</Dialog.Text>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="username" value={username} />
</Dialog.Panel>
</Dialog>
);
}

View File

@ -1,35 +1,34 @@
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { useState } from 'react';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Input from '~/components/Input'; import Input from '~/components/Input';
import { User } from '~/types';
interface Props { interface Props {
username: string; user: User;
} }
// TODO: Server side validation before submitting // TODO: Server side validation before submitting
export default function Rename({ username }: Props) { export default function RenameUser({ user }: Props) {
const [newName, setNewName] = useState(username);
return ( return (
<Dialog> <Dialog>
<Dialog.IconButton label={`Rename ${username}`}> <Dialog.IconButton label={`Rename ${user.name}`}>
<Pencil className="p-1" /> <Pencil className="p-1" />
</Dialog.IconButton> </Dialog.IconButton>
<Dialog.Panel> <Dialog.Panel>
<Dialog.Title>Rename {username}?</Dialog.Title> <Dialog.Title>Rename {user.name}?</Dialog.Title>
<Dialog.Text className="mb-8"> <Dialog.Text className="mb-6">
Enter a new username for {username}. Changing a username will not Enter a new username for {user.name}. Changing a username will not
update any ACL policies that may refer to this user by their old update any ACL policies that may refer to this user by their old
username. username.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="rename" /> <input type="hidden" name="action_id" value="rename_user" />
<input type="hidden" name="old" value={username} /> <input type="hidden" name="user_id" value={user.id} />
<Input <Input
isRequired isRequired
name="new" name="new_name"
label="Username" label="Username"
placeholder="my-new-name" placeholder="my-new-name"
defaultValue={user.name}
/> />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>

View File

@ -2,7 +2,7 @@ import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react'; import { PersonIcon } from '@primer/octicons-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useActionData, useLoaderData, useSubmit } from 'react-router'; import { useLoaderData, useSubmit } from 'react-router';
import { ClientOnly } from 'remix-utils/client-only'; import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute'; import Attribute from '~/components/Attribute';
@ -11,16 +11,14 @@ import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle'; import StatusCircle from '~/components/StatusCircle';
import type { Machine, User } from '~/types'; import type { Machine, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { del, post, pull } from '~/utils/headscale'; import { pull } from '~/utils/headscale';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server'; import { getSession } from '~/utils/sessions.server';
import { hp_getConfig, hs_getConfig } from '~/utils/state'; import { hp_getConfig, hs_getConfig } from '~/utils/state';
import toast from '~/utils/toast'; import ManageBanner from './components/manage-banner';
import Auth from './components/auth'; import DeleteUser from './dialogs/delete-user';
import Oidc from './components/oidc'; import RenameUser from './dialogs/rename-user';
import Remove from './dialogs/remove'; import { userAction } from './user-actions';
import Rename from './dialogs/rename';
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')); const session = await getSession(request.headers.get('Cookie'));
@ -52,94 +50,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
}; };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action(data: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')); return userAction(data);
if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, 401);
}
const data = await request.formData();
if (!data.has('_method')) {
return send({ message: 'No method provided' }, 400);
}
const method = String(data.get('_method'));
switch (method) {
case 'create': {
if (!data.has('username')) {
return send({ message: 'No name provided' }, 400);
}
const username = String(data.get('username'));
await post('v1/user', session.get('hsApiKey')!, {
name: username,
});
return { message: `User ${username} created` };
}
case 'delete': {
if (!data.has('username')) {
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` };
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
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}` };
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
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'));
try {
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);
}
}
default: {
return send({ message: 'Invalid method' }, 400);
}
}
} }
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState(data.users); const [users, setUsers] = useState<UserMachine[]>(data.users);
const actionData = useActionData<typeof action>();
useEffect(() => {
if (!actionData) {
return;
}
toast(actionData.message);
if (actionData.message.startsWith('Failed')) {
setUsers(data.users);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionData]);
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
useEffect(() => { useEffect(() => {
setUsers(data.users); setUsers(data.users);
}, [data.users]); }, [data.users]);
@ -151,7 +73,7 @@ export default function Page() {
Manage the users in your network and their permissions. Tip: You can Manage the users in your network and their permissions. Tip: You can
drag machines between users to change ownership. drag machines between users to change ownership.
</p> </p>
{data.oidc ? <Oidc oidc={data.oidc} /> : <Auth magic={data.magic} />} <ManageBanner oidc={data.oidc} />
<ClientOnly fallback={<Users users={users} />}> <ClientOnly fallback={<Users users={users} />}>
{() => ( {() => (
<InteractiveUsers <InteractiveUsers
@ -218,10 +140,9 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
setUsers?.(newUsers); setUsers?.(newUsers);
const data = new FormData(); const data = new FormData();
data.append('_method', 'move'); data.append('action_id', 'change_owner');
data.append('id', active.id.toString()); data.append('user_id', over.id.toString());
data.append('to', over.id.toString()); data.append('node_id', reference.current.id);
data.append('name', reference.current.givenName);
submit(data, { submit(data, {
method: 'POST', method: 'POST',
@ -293,9 +214,9 @@ function UserCard({ user, magic }: CardProps) {
<span className="text-lg font-mono">{user.name}</span> <span className="text-lg font-mono">{user.name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Rename username={user.name} /> <RenameUser user={user} />
{user.machines.length === 0 ? ( {user.machines.length === 0 ? (
<Remove username={user.name} /> <DeleteUser user={user} />
) : undefined} ) : undefined}
</div> </div>
</div> </div>

View File

@ -0,0 +1,81 @@
import { ActionFunctionArgs, data } from 'react-router';
import { del, post } from '~/utils/headscale';
import { auth } from '~/utils/sessions.server';
export async function userAction({ request }: ActionFunctionArgs) {
const session = await auth(request);
if (!session) {
return data({ success: false }, 401);
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
const apiKey = session.get('hsApiKey');
if (!apiKey) {
return data({ success: false }, 401);
}
switch (action) {
case 'create_user':
return createUser(formData, apiKey);
case 'delete_user':
return deleteUser(formData, apiKey);
case 'rename_user':
return renameUser(formData, apiKey);
case 'change_owner':
return changeOwner(formData, apiKey);
default:
return data({ success: false }, 400);
}
}
async function createUser(formData: FormData, apiKey: string) {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
return data({ success: false }, 400);
}
await post('v1/user', apiKey, {
name,
displayName,
email,
});
}
async function deleteUser(formData: FormData, apiKey: string) {
const userId = formData.get('user_id')?.toString();
if (!userId) {
return data({ success: false }, 400);
}
await del(`v1/user/${userId}`, apiKey);
}
async function renameUser(formData: FormData, apiKey: string) {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
await post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function changeOwner(formData: FormData, apiKey: string) {
const userId = formData.get('user_id')?.toString();
const nodeId = formData.get('node_id')?.toString();
if (!userId || !nodeId) {
return data({ success: false }, 400);
}
await post(`v1/node/${nodeId}/user`, apiKey, {
user: userId,
});
}

View File

@ -2,4 +2,9 @@ export interface User {
id: string; id: string;
name: string; name: string;
createdAt: string; createdAt: string;
displayName?: string;
email?: string;
providerId?: string;
provider?: string;
profilePicUrl?: string;
} }

View File

@ -53,6 +53,7 @@ export function getSession(cookie: string | null) {
return sessionStorage.getSession(cookie); return sessionStorage.getSession(cookie);
} }
export type ServerSession = Session<SessionData, SessionFlashData>;
export async function auth(request: Request) { export async function auth(request: Request) {
if (!sessionStorage) { if (!sessionStorage) {
return false; return false;
@ -64,7 +65,7 @@ export async function auth(request: Request) {
return false; return false;
} }
return true; return session;
} }
export function destroySession(session: Session) { export function destroySession(session: Session) {