mirror of
https://github.com/tale/headplane.git
synced 2026-08-21 10:16:37 +00:00
feat: redo user page to match account linking
This commit is contained in:
@@ -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 “Unlinked
|
||||
Headscale Users” 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user