diff --git a/app/routes/users/components/headplane-user-row.tsx b/app/routes/users/components/headplane-user-row.tsx index e6b885c..c062a1c 100644 --- a/app/routes/users/components/headplane-user-row.tsx +++ b/app/routes/users/components/headplane-user-row.tsx @@ -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 diff --git a/app/routes/users/components/menu.tsx b/app/routes/users/components/menu.tsx index 93ce028..195d29f 100644 --- a/app/routes/users/components/menu.tsx +++ b/app/routes/users/components/menu.tsx @@ -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(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" && ( + { + if (!isOpen) setModal(null); + }} + targetDisplayName={displayName} + targetUserId={user.linkedHeadscaleUser?.id ?? user.id} + /> + )} @@ -81,6 +99,14 @@ export default function UserMenu({ user, headscaleUsers, currentLink, isSelf }: setModal("link")}> {isLinked ? "Change linked user" : "Link Headscale user"} + {isOwner && !isSelf && ( + <> + + setModal("transfer")}> + Transfer ownership + + + )} {user.linkedHeadscaleUser && !isSelf && ( <> diff --git a/app/routes/users/dialogs/transfer-ownership.tsx b/app/routes/users/dialogs/transfer-ownership.tsx new file mode 100644 index 0000000..bdac150 --- /dev/null +++ b/app/routes/users/dialogs/transfer-ownership.tsx @@ -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 ( + + + Transfer ownership to {targetDisplayName}? + + This will make {targetDisplayName} the new owner of this Headplane instance. You will be + demoted to an Admin. This action cannot be easily undone. + + + Only the owner can transfer ownership. After this, you will no longer be able to manage + ownership. + + + + + + ); +} diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index ea045db..2782303 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -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) => ( 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(); diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index 7da8f74..0e71bee 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -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 { + 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.