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; user: HeadplaneUserData;
headscaleUsers: { id: string; name: string; claimed: boolean }[]; headscaleUsers: { id: string; name: string; claimed: boolean }[];
isSelf?: 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 isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce( const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()), (acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -67,6 +73,7 @@ export default function HeadplaneUserRow({ user, headscaleUsers, isSelf }: Headp
<MenuOptions <MenuOptions
currentLink={user.headscaleUserId ?? undefined} currentLink={user.headscaleUserId ?? undefined}
headscaleUsers={headscaleUsers} headscaleUsers={headscaleUsers}
isOwner={isOwner}
isSelf={isSelf} isSelf={isSelf}
user={user} 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 Delete from "../dialogs/delete-user";
import LinkUser from "../dialogs/link-user"; import LinkUser from "../dialogs/link-user";
import Reassign from "../dialogs/reassign-user"; import Reassign from "../dialogs/reassign-user";
import TransferOwnership from "../dialogs/transfer-ownership";
import type { HeadplaneUserData } from "../overview"; import type { HeadplaneUserData } from "../overview";
interface MenuProps { interface MenuProps {
@@ -13,11 +14,18 @@ interface MenuProps {
headscaleUsers: { id: string; name: string; claimed: boolean }[]; headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string; currentLink?: string;
isSelf?: boolean; 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 [modal, setModal] = useState<Modal>(null);
const isLinked = currentLink !== undefined; const isLinked = currentLink !== undefined;
@@ -66,6 +74,16 @@ export default function UserMenu({ user, headscaleUsers, currentLink, isSelf }:
userId={user.linkedHeadscaleUser?.id ?? user.id} 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> <Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800"> <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")}> <MenuItem onClick={() => setModal("link")}>
{isLinked ? "Change linked user" : "Link Headscale user"} {isLinked ? "Change linked user" : "Link Headscale user"}
</MenuItem> </MenuItem>
{isOwner && !isSelf && (
<>
<MenuSeparator />
<MenuItem variant="danger" onClick={() => setModal("transfer")}>
Transfer ownership
</MenuItem>
</>
)}
{user.linkedHeadscaleUser && !isSelf && ( {user.linkedHeadscaleUser && !isSelf && (
<> <>
<MenuSeparator /> <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 { return {
writable: writablePermission, writable: writablePermission,
currentUserId: principal.kind === "oidc" ? principal.user.id : undefined, currentUserId: principal.kind === "oidc" ? principal.user.id : undefined,
isOwner,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined, oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
magic, magic,
apiError, apiError,
@@ -188,6 +191,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
{loaderData.headplaneUsers.map((user) => ( {loaderData.headplaneUsers.map((user) => (
<HeadplaneUserRow <HeadplaneUserRow
isSelf={user.id === loaderData.currentUserId} isSelf={user.id === loaderData.currentUserId}
isOwner={loaderData.isOwner}
key={user.id} key={user.id}
headscaleUsers={loaderData.headscaleUsersForLink} headscaleUsers={loaderData.headscaleUsersForLink}
user={user} user={user}
+28
View File
@@ -104,6 +104,34 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "User reassigned successfully" }; 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": { case "link_user": {
const userId = formData.get("user_id")?.toString(); const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_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; 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. * Reassign the role of a user identified by their OIDC subject.
* Cannot reassign the owner role. * Cannot reassign the owner role.