feat: add ownership transfer support

This commit is contained in:
Aarnav Tale
2026-03-14 15:17:45 -04:00
parent 55ddbe0987
commit a26faab139
6 changed files with 142 additions and 3 deletions
@@ -11,9 +11,15 @@ interface HeadplaneUserRowProps {
user: HeadplaneUserData;
headscaleUsers: { id: string; name: string; claimed: boolean }[];
isSelf?: boolean;
isOwner?: boolean;
}
export default function HeadplaneUserRow({ user, headscaleUsers, isSelf }: HeadplaneUserRowProps) {
export default function HeadplaneUserRow({
user,
headscaleUsers,
isSelf,
isOwner,
}: HeadplaneUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -67,6 +73,7 @@ export default function HeadplaneUserRow({ user, headscaleUsers, isSelf }: Headp
<MenuOptions
currentLink={user.headscaleUserId ?? undefined}
headscaleUsers={headscaleUsers}
isOwner={isOwner}
isSelf={isSelf}
user={user}
/>
+28 -2
View File
@@ -6,6 +6,7 @@ import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/compo
import Delete from "../dialogs/delete-user";
import LinkUser from "../dialogs/link-user";
import Reassign from "../dialogs/reassign-user";
import TransferOwnership from "../dialogs/transfer-ownership";
import type { HeadplaneUserData } from "../overview";
interface MenuProps {
@@ -13,11 +14,18 @@ interface MenuProps {
headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string;
isSelf?: boolean;
isOwner?: boolean;
}
type Modal = "delete" | "reassign" | "link" | null;
type Modal = "delete" | "reassign" | "link" | "transfer" | null;
export default function UserMenu({ user, headscaleUsers, currentLink, isSelf }: MenuProps) {
export default function UserMenu({
user,
headscaleUsers,
currentLink,
isSelf,
isOwner,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const isLinked = currentLink !== undefined;
@@ -66,6 +74,16 @@ export default function UserMenu({ user, headscaleUsers, currentLink, isSelf }:
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "transfer" && (
<TransferOwnership
isOpen={modal === "transfer"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
targetDisplayName={displayName}
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
@@ -81,6 +99,14 @@ export default function UserMenu({ user, headscaleUsers, currentLink, isSelf }:
<MenuItem onClick={() => setModal("link")}>
{isLinked ? "Change linked user" : "Link Headscale user"}
</MenuItem>
{isOwner && !isSelf && (
<>
<MenuSeparator />
<MenuItem variant="danger" onClick={() => setModal("transfer")}>
Transfer ownership
</MenuItem>
</>
)}
{user.linkedHeadscaleUser && !isSelf && (
<>
<MenuSeparator />
@@ -0,0 +1,34 @@
import Dialog from "~/components/Dialog";
import Notice from "~/components/Notice";
interface TransferOwnershipProps {
targetUserId: string;
targetDisplayName: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function TransferOwnership({
targetUserId,
targetDisplayName,
isOpen,
setIsOpen,
}: TransferOwnershipProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Transfer ownership to {targetDisplayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
This will make {targetDisplayName} the new owner of this Headplane instance. You will be
demoted to an Admin. This action cannot be easily undone.
</Dialog.Text>
<Notice variant="warning">
Only the owner can transfer ownership. After this, you will no longer be able to manage
ownership.
</Notice>
<input name="action_id" type="hidden" value="transfer_ownership" />
<input name="user_id" type="hidden" value={targetUserId} />
</Dialog.Panel>
</Dialog>
);
}
+4
View File
@@ -126,9 +126,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
}
const isOwner = principal.kind === "oidc" && principal.user.role === "owner";
return {
writable: writablePermission,
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
isOwner,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
magic,
apiError,
@@ -188,6 +191,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
{loaderData.headplaneUsers.map((user) => (
<HeadplaneUserRow
isSelf={user.id === loaderData.currentUserId}
isOwner={loaderData.isOwner}
key={user.id}
headscaleUsers={loaderData.headscaleUsersForLink}
user={user}
+28
View File
@@ -104,6 +104,34 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "User reassigned successfully" };
}
case "transfer_ownership": {
if (principal.kind !== "oidc" || principal.user.role !== "owner") {
throw data("Only the owner can transfer ownership.", { status: 403 });
}
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", { status: 400 });
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const targetSubject = getOidcSubject(user);
if (!targetSubject) {
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
if (!result) {
throw data("Failed to transfer ownership.", { status: 500 });
}
return { message: "Ownership transferred successfully" };
}
case "link_user": {
const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
+40
View File
@@ -434,6 +434,46 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Transfer ownership from the current owner to another user.
* The current owner is demoted to admin and the target is promoted
* to owner. Both users must exist. Returns false if the caller is
* not actually the owner or the target doesn't exist.
*/
async transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean> {
const [current] = await this.opts.db
.select()
.from(users)
.where(eq(users.sub, currentOwnerSubject))
.limit(1);
if (!current || current.role !== "owner") {
return false;
}
const [target] = await this.opts.db
.select()
.from(users)
.where(eq(users.sub, newOwnerSubject))
.limit(1);
if (!target || target.id === current.id) {
return false;
}
await this.opts.db
.update(users)
.set({ role: "admin", caps: capsForRole("admin"), updated_at: new Date() })
.where(eq(users.id, current.id));
await this.opts.db
.update(users)
.set({ role: "owner", caps: capsForRole("owner"), updated_at: new Date() })
.where(eq(users.id, target.id));
return true;
}
/**
* Reassign the role of a user identified by their OIDC subject.
* Cannot reassign the owner role.