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

View File

@ -2,7 +2,7 @@ import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react';
import { useEffect, useState } from 'react';
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 Attribute from '~/components/Attribute';
@ -11,16 +11,14 @@ import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { del, post, pull } from '~/utils/headscale';
import { send } from '~/utils/res';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { hp_getConfig, hs_getConfig } from '~/utils/state';
import toast from '~/utils/toast';
import Auth from './components/auth';
import Oidc from './components/oidc';
import Remove from './dialogs/remove';
import Rename from './dialogs/rename';
import ManageBanner from './components/manage-banner';
import DeleteUser from './dialogs/delete-user';
import RenameUser from './dialogs/rename-user';
import { userAction } from './user-actions';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
@ -52,94 +50,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
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 async function action(data: ActionFunctionArgs) {
return userAction(data);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState(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]);
const [users, setUsers] = useState<UserMachine[]>(data.users);
// 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(() => {
setUsers(data.users);
}, [data.users]);
@ -151,7 +73,7 @@ export default function Page() {
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} /> : <Auth magic={data.magic} />}
<ManageBanner oidc={data.oidc} />
<ClientOnly fallback={<Users users={users} />}>
{() => (
<InteractiveUsers
@ -218,10 +140,9 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
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);
data.append('action_id', 'change_owner');
data.append('user_id', over.id.toString());
data.append('node_id', reference.current.id);
submit(data, {
method: 'POST',
@ -293,9 +214,9 @@ function UserCard({ user, magic }: CardProps) {
<span className="text-lg font-mono">{user.name}</span>
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} />
<RenameUser user={user} />
{user.machines.length === 0 ? (
<Remove username={user.name} />
<DeleteUser user={user} />
) : undefined}
</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;
name: 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);
}
export type ServerSession = Session<SessionData, SessionFlashData>;
export async function auth(request: Request) {
if (!sessionStorage) {
return false;
@ -64,7 +65,7 @@ export async function auth(request: Request) {
return false;
}
return true;
return session;
}
export function destroySession(session: Session) {