feat: fix acl api logic to work correctly

This commit is contained in:
Aarnav Tale
2025-11-04 23:16:49 -05:00
parent c84e9ca4a8
commit 444b2325fb
37 changed files with 772 additions and 1218 deletions
@@ -1,17 +1,16 @@
import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import type { HeadplaneConfig } from '~/server/config/schema';
import CreateUser from '../dialogs/create-user';
interface ManageBannerProps {
oidc?: NonNullable<HeadplaneConfig['oidc']>;
oidc?: { issuer: string };
isDisabled?: boolean;
}
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
<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 ? (
@@ -26,14 +25,14 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
{oidc ? (
<>
Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider">
<Link name="OIDC Provider" to={oidc.issuer}>
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
@@ -43,8 +42,8 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
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"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
@@ -61,7 +60,7 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
: 'You can add, remove, and rename users here.'}
</p>
<div className="flex items-center gap-2 mt-4">
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} />
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div>
</div>
</div>
+4 -4
View File
@@ -22,40 +22,40 @@ export default function UserMenu({ user }: MenuProps) {
<>
{modal === 'rename' && (
<Rename
user={user}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'delete' && (
<Delete
user={user}
isOpen={modal === 'delete'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'reassign' && (
<Reassign
user={user}
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
+7 -5
View File
@@ -18,22 +18,24 @@ export default function UserRow({ user, role }: UserRowProps) {
return (
<tr
key={user.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={user.id}
>
<td className="pl-0.5 py-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
src={user.profilePicUrl}
alt={user.name || user.displayName}
className="w-10 h-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="w-10 h-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
<p className={cn('font-semibold leading-snug')}>
{user.name || user.displayName}
</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
@@ -43,8 +45,8 @@ export default function UserRow({ user, role }: UserRowProps) {
</td>
<td className="pl-0.5 py-2">
<p
suppressHydrationWarning
className="text-sm text-headplane-600 dark:text-headplane-300"
suppressHydrationWarning
>
{new Date(user.createdAt).toLocaleDateString()}
</p>
@@ -56,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
</p>
+12 -12
View File
@@ -24,15 +24,14 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
</>
) : undefined}
</Dialog.Text>
<input type="hidden" name="action_id" value="create_user" />
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
name="username"
type="text"
label="Username"
name="username"
placeholder="my-new-user"
validationBehavior="native"
type="text"
validate={(value) => {
if (value.trim().length === 0) {
return 'Username is required';
@@ -44,19 +43,20 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
return true;
}}
/>
<Input
name="display_name"
type="text"
label="Display Name"
placeholder="John Doe"
validationBehavior="native"
/>
<Input
name="email"
type="email"
label="Display Name"
name="display_name"
placeholder="John Doe"
type="text"
validationBehavior="native"
/>
<Input
label="Email"
name="email"
placeholder="name@example.com"
type="email"
validationBehavior="native"
/>
</div>
+2 -2
View File
@@ -32,8 +32,8 @@ export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
)}
</Dialog.Text>
)}
<input type="hidden" name="action_id" value="delete_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
</Dialog.Panel>
</Dialog>
);
+10 -8
View File
@@ -21,14 +21,16 @@ export default function ReassignUser({
<Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
>
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Title>
Change role for {user.name || user.displayName}?
</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
to="https://tailscale.com/kb/1138/user-roles"
name="Tailscale User Roles documentation"
to="https://tailscale.com/kb/1138/user-roles"
>
Learn More
</Link>
@@ -37,21 +39,21 @@ export default function ReassignUser({
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input type="hidden" name="action_id" value="reassign_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<RadioGroup
isRequired
name="new_role"
label="Role"
className="gap-4"
defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} value={role} label={name}>
<RadioGroup.Radio key={role} label={name} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
+9 -9
View File
@@ -15,18 +15,18 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
<Dialog.Panel>
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
Enter a new username for {user.name || user.displayName}. Changing a username will not
update any ACL policies that may refer to this user by their old
username.
Enter a new username for {user.name || user.displayName}. 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="action_id" value="rename_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<Input
isRequired
name="new_name"
label="Username"
placeholder="my-new-name"
defaultValue={user.name}
isRequired
label="Username"
name="new_name"
placeholder="my-new-name"
/>
</Dialog.Panel>
</Dialog>
+3 -6
View File
@@ -1,12 +1,9 @@
import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
import { redirect } from 'react-router';
import { users } from '~/server/db/schema';
import type { Route } from './+types/onboarding-skip';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { user } = await context.sessions.auth(request);
await context.db
+8 -13
View File
@@ -1,23 +1,20 @@
import { Icon } from '@iconify/react';
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
import { NavLink } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
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';
import toast from '~/utils/toast';
import type { Route } from './+types/onboarding';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
@@ -48,13 +45,10 @@ export async function loader({
break;
}
const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined;
try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.api_key,
);
const nodes = await api.getNodes();
const node = nodes.find((n) => {
if (n.user.provider !== 'oidc') {
return false;
@@ -87,8 +81,9 @@ export async function loader({
};
}
export default function Page() {
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { user, osValue, firstMachine },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
+19 -25
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import type { Route } from './+types/overview';
import ManageBanner from './components/manage-banner';
import UserRow from './components/user-row';
import { userAction } from './user-actions';
@@ -13,10 +11,7 @@ interface UserMachine extends User {
machines: Machine[];
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
@@ -31,14 +26,12 @@ export async function loader({
Capabilities.write_users,
);
const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const users = apiUsers.users.map((user) => ({
const users = apiUsers.map((user) => ({
...user,
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
machines: nodes.filter((node) => node.user.id === user.id),
}));
const roles = await Promise.all(
@@ -77,28 +70,29 @@ export async function loader({
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc,
oidc: context.config.oidc
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
magic,
users,
};
}
export async function action(data: ActionFunctionArgs) {
return userAction(data);
}
export const action = userAction;
export default function Page() {
const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState<UserMachine[]>(data.users);
export default function Page({ loaderData }: Route.ComponentProps) {
const [users, setUsers] = useState<UserMachine[]>(loaderData.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]);
setUsers(loaderData.users);
}, [loaderData.users]);
return (
<>
@@ -106,7 +100,7 @@ export default function Page() {
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
</p>
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
@@ -127,7 +121,7 @@ export default function Page() {
.map((user) => (
<UserRow
key={user.id}
role={data.roles[users.indexOf(user)]}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
+99 -124
View File
@@ -1,140 +1,115 @@
import { ActionFunctionArgs, data } from 'react-router';
import type { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities, Roles } from '~/server/web/roles';
import { User } from '~/types';
import { data400, data403 } from '~/utils/res';
import type { Route } from './+types/overview';
export async function userAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function userAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
throw data403('You do not have permission to update users');
throw data('You do not have permission to update users', {
status: 403,
});
}
const apiKey = session.api_key;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data400('Missing `action_id` in the form data.');
throw data('Missing `action_id` in the form data.', {
status: 404,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
switch (action) {
case 'create_user':
return createUser(formData, apiKey, context);
case 'delete_user':
return deleteUser(formData, apiKey, context);
case 'rename_user':
return renameUser(formData, apiKey, context);
case 'reassign_user':
return reassignUser(formData, apiKey, context);
case 'create_user': {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
throw data('Missing `username` in the form data.', {
status: 400,
});
}
await api.createUser(name, email, displayName);
return { message: 'User created successfully' };
}
case 'delete_user': {
const userId = formData.get('user_id')?.toString();
if (!userId) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
await api.deleteUser(userId);
return { message: 'User deleted successfully' };
}
case 'rename_user': {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data('Users managed by OIDC cannot be renamed', {
status: 403,
});
}
await api.renameUser(userId, newName);
return { message: 'User renamed successfully' };
}
case 'reassign_user': {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data('Missing `user_id` or `new_role` in the form data.', {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data('Specified user is not an OIDC user', {
status: 400,
});
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
throw data(
'Malformed `providerId` for the specified user. Cannot find subject.',
{ status: 400 },
);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
throw data('Failed to reassign user role.', { status: 500 });
}
return { message: 'User reassigned successfully' };
}
default:
throw data400('Invalid `action_id` provided.');
throw data('Invalid `action_id` provided.', {
status: 400,
});
}
}
async function createUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
throw data400('Missing `username` in the form data.');
}
await context.client.post('v1/user', apiKey, {
name,
displayName,
email,
});
}
async function deleteUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
if (!userId) {
throw data400('Missing `user_id` in the form data.');
}
await context.client.delete(`v1/user/${userId}`, apiKey);
}
async function renameUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data400(`No user found with id: ${userId}`);
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data403('Users managed by OIDC cannot be renamed');
}
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function reassignUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data400('Missing `user_id` or `new_role` in the form data.');
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data400('Specified user is not an OIDC user');
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
throw data400(
'Malformed `providerId` for the specified user. Cannot find subject.',
);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
return data({ success: false }, 403);
}
return data({ success: true });
}