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