feat: redo user page to match account linking

This commit is contained in:
Aarnav Tale
2026-03-14 14:56:04 -04:00
parent 225a52ded6
commit e255407115
11 changed files with 517 additions and 373 deletions
+52 -58
View File
@@ -1,66 +1,60 @@
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
interface CreateUserProps {
isOidc?: boolean;
isDisabled?: boolean;
isOidc?: boolean;
isDisabled?: boolean;
}
// TODO: Support image upload for user avatars
export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>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.
{isOidc ? (
<>
{' '}
Manually created users are given administrative access to
Headplane unless they become linked to an OIDC user in Headscale.
</>
) : undefined}
</Dialog.Text>
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
label="Username"
name="username"
placeholder="my-new-user"
type="text"
validate={(value) => {
if (value.trim().length === 0) {
return 'Username is required';
}
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Create a Headscale user</Dialog.Title>
<Dialog.Text className="mb-6">
This creates a new user in Headscale. The user will appear in the &ldquo;Unlinked
Headscale Users&rdquo; section until they sign in
{isOidc ? " through your OIDC provider" : ""} and are automatically linked to a Headplane
account.
</Dialog.Text>
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
label="Username"
name="username"
placeholder="my-new-user"
type="text"
validate={(value) => {
if (value.trim().length === 0) {
return "Username is required";
}
if (value.includes(' ')) {
return 'Usernames cannot contain spaces';
}
if (value.includes(" ")) {
return "Usernames cannot contain spaces";
}
return true;
}}
validationBehavior="native"
/>
<Input
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>
</Dialog.Panel>
</Dialog>
);
return true;
}}
validationBehavior="native"
/>
<Input
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>
</Dialog.Panel>
</Dialog>
);
}
+6 -5
View File
@@ -1,20 +1,21 @@
import Dialog from "~/components/Dialog";
import { Machine, User } from "~/types";
import type { Machine, User } from "~/types";
interface DeleteProps {
user: User & { machines: Machine[] };
user: User;
machines: Machine[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
export default function DeleteUser({ user, machines, isOpen, setIsOpen }: DeleteProps) {
const name = user.name || user.displayName;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={user.machines.length > 0 ? "unactionable" : "normal"}>
<Dialog.Panel variant={machines.length > 0 ? "unactionable" : "normal"}>
<Dialog.Title>Delete {name}?</Dialog.Title>
{user.machines.length > 0 ? (
{machines.length > 0 ? (
<Dialog.Text className="mb-6">
Users cannot be deleted if they have machines. Please delete or re-assign their machines
to other users before proceeding.
+7 -6
View File
@@ -1,10 +1,10 @@
import Dialog from "~/components/Dialog";
import Notice from "~/components/Notice";
import type { User } from "~/types";
import cn from "~/utils/cn";
interface LinkUserProps {
user: User & { headplaneRole: string };
userId: string;
displayName: string;
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
isOpen: boolean;
@@ -12,7 +12,8 @@ interface LinkUserProps {
}
export default function LinkUser({
user,
userId,
displayName,
headscaleUsers,
currentLink,
isOpen,
@@ -21,9 +22,9 @@ export default function LinkUser({
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Link Headscale user for {user.name || user.displayName}</Dialog.Title>
<Dialog.Title>Link Headscale user for {displayName}</Dialog.Title>
<Dialog.Text className="mb-6">
Select which Headscale user this OIDC identity should be linked to. This controls which
Select which Headscale user this identity should be linked to. This controls which
machines they can manage and enables self-service features.
</Dialog.Text>
{headscaleUsers.length === 0 ? (
@@ -31,7 +32,7 @@ export default function LinkUser({
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="user_id" type="hidden" value={userId} />
<select
className={cn(
"w-full rounded-lg border p-2",
+29 -17
View File
@@ -3,46 +3,53 @@ import Link from "~/components/link";
import Notice from "~/components/Notice";
import RadioGroup from "~/components/RadioGroup";
import { Roles } from "~/server/web/roles";
import { User } from "~/types";
import type { Role } from "~/server/web/roles";
interface ReassignProps {
user: User & { headplaneRole: string };
userId: string;
displayName: string;
role: Role;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ReassignUser({ user, isOpen, setIsOpen }: ReassignProps) {
export default function ReassignUser({
userId,
displayName,
role,
isOpen,
setIsOpen,
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={user.headplaneRole === "owner" ? "unactionable" : "normal"}>
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Panel variant={role === "owner" ? "unactionable" : "normal"}>
<Dialog.Title>Change role for {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.{" "}
Roles control what the user can access in Headplane. Each role grants a specific set of
capabilities.{" "}
<Link external styled to="https://tailscale.com/kb/1138/user-roles">
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === "owner" ? (
{role === "owner" ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="user_id" type="hidden" value={userId} />
<RadioGroup
className="gap-4"
defaultValue={user.headplaneRole}
defaultValue={role}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== "owner")
.map((role) => {
const { name, desc } = mapRoleToName(role);
.filter((r) => r !== "owner")
.map((r) => {
const { name, desc } = mapRoleToName(r);
return (
<RadioGroup.Radio key={role} label={name} value={role}>
<RadioGroup.Radio key={r} label={name} value={r}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
@@ -80,6 +87,11 @@ function mapRoleToName(role: string) {
name: "Auditor",
desc: "Can view the admin console.",
};
case "viewer":
return {
name: "Viewer",
desc: "Can view machines, users, and generate their own auth keys.",
};
case "member":
return {
name: "Member",
@@ -87,8 +99,8 @@ function mapRoleToName(role: string) {
};
default:
return {
name: "Unknown",
desc: "Unknown",
name: role,
desc: "No description available.",
};
}
}