mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
feat: add ownership transfer support
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user