diff --git a/app/routes/users/components/menu.tsx b/app/routes/users/components/menu.tsx index a3fb12b..0cb4ef9 100644 --- a/app/routes/users/components/menu.tsx +++ b/app/routes/users/components/menu.tsx @@ -1,74 +1,102 @@ -import { Ellipsis } from 'lucide-react'; -import { useState } from 'react'; -import Menu from '~/components/Menu'; -import type { Machine, User } from '~/types'; -import cn from '~/utils/cn'; -import Delete from '../dialogs/delete-user'; -import Reassign from '../dialogs/reassign-user'; -import Rename from '../dialogs/rename-user'; +import { Ellipsis } from "lucide-react"; +import { useState } from "react"; + +import Menu from "~/components/Menu"; +import type { Machine, User } from "~/types"; +import cn from "~/utils/cn"; + +import Delete from "../dialogs/delete-user"; +import LinkUser from "../dialogs/link-user"; +import Reassign from "../dialogs/reassign-user"; +import Rename from "../dialogs/rename-user"; interface MenuProps { - user: User & { - headplaneRole: string; - machines: Machine[]; - }; + user: User & { + headplaneRole: string; + machines: Machine[]; + }; + headscaleUsers: { id: string; name: string; claimed: boolean }[]; + currentLink?: string; } -type Modal = 'rename' | 'delete' | 'reassign' | null; +type Modal = "rename" | "delete" | "reassign" | "link" | null; -export default function UserMenu({ user }: MenuProps) { - const [modal, setModal] = useState(null); - return ( - <> - {modal === 'rename' && ( - { - if (!isOpen) setModal(null); - }} - user={user} - /> - )} - {modal === 'delete' && ( - { - if (!isOpen) setModal(null); - }} - user={user} - /> - )} - {modal === 'reassign' && ( - { - if (!isOpen) setModal(null); - }} - user={user} - /> - )} +export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) { + const [modal, setModal] = useState(null); - - - - - setModal(key as Modal)}> - - Rename user - Change role - -

Delete

-
-
-
-
- - ); + const disabledKeys: string[] = []; + if (user.provider === "oidc") { + disabledKeys.push("rename"); + } else { + disabledKeys.push("reassign", "link"); + } + + // Filter linkable users: unclaimed, or the one currently linked to this user + const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink); + + return ( + <> + {modal === "rename" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + {modal === "delete" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + {modal === "reassign" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + {modal === "link" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + + + + + + setModal(key as Modal)}> + + Rename user + Change role + Link Headscale user + +

Delete

+
+
+
+
+ + ); } diff --git a/app/routes/users/components/user-row.tsx b/app/routes/users/components/user-row.tsx index 2e5586c..2be65f6 100644 --- a/app/routes/users/components/user-row.tsx +++ b/app/routes/users/components/user-row.tsx @@ -9,9 +9,11 @@ import MenuOptions from "./menu"; interface UserRowProps { role: string; user: User & { machines: Machine[] }; + headscaleUsers: { id: string; name: string; claimed: boolean }[]; + currentLink?: string; } -export default function UserRow({ user, role }: UserRowProps) { +export default function UserRow({ user, role, headscaleUsers, currentLink }: UserRowProps) { const isOnline = user.machines.some((machine) => machine.online); const lastSeen = user.machines.reduce( (acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()), @@ -59,7 +61,11 @@ export default function UserRow({ user, role }: UserRowProps) { - + ); diff --git a/app/routes/users/dialogs/link-user.tsx b/app/routes/users/dialogs/link-user.tsx new file mode 100644 index 0000000..e1d9fae --- /dev/null +++ b/app/routes/users/dialogs/link-user.tsx @@ -0,0 +1,58 @@ +import Dialog from "~/components/Dialog"; +import Notice from "~/components/Notice"; +import type { User } from "~/types"; +import cn from "~/utils/cn"; + +interface LinkUserProps { + user: User & { headplaneRole: string }; + headscaleUsers: { id: string; name: string }[]; + currentLink?: string; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +} + +export default function LinkUser({ + user, + headscaleUsers, + currentLink, + isOpen, + setIsOpen, +}: LinkUserProps) { + return ( + + + Link Headscale user for {user.name || user.displayName} + + Select which Headscale user this OIDC identity should be linked to. This controls which + machines they can manage and enables self-service features. + + {headscaleUsers.length === 0 ? ( + All Headscale users are already linked to other accounts. + ) : ( + <> + + + + + )} + + + ); +} diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index b0ff8de..48beb6e 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -125,34 +125,47 @@ export default function Page({ return (
- {needsUserLink && headscaleUsers.length > 0 ? ( + {needsUserLink ? ( Link your Headscale account - Headplane couldn't automatically match your SSO identity to a Headscale user. Select - which Headscale user you are to continue. + Headplane couldn't automatically match your SSO identity to a Headscale user. + {headscaleUsers.length > 0 + ? " Select which Headscale user you are, or skip to continue without linking." + : " All Headscale users are already linked. You can skip this step and ask an admin to link your account later."} -
- - +
+ ) : undefined} + + - + +

+ Without linking, you won't be able to see your own machines or generate pre-auth keys. + An admin can link your account later from the Users page. +

) : undefined} diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 66d0a62..451aec3 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,11 +1,14 @@ import { createHash } from "node:crypto"; +import { eq } from "drizzle-orm"; import { useEffect, useState } from "react"; +import { users as usersTable } from "~/server/db/schema"; import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; import type { Machine, User } from "~/types"; import cn from "~/utils/cn"; +import { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/overview"; import ManageBanner from "./components/manage-banner"; @@ -74,6 +77,28 @@ export async function loader({ request, context }: Route.LoaderArgs) { } } + // Build linkable Headscale users for admin link dialog + const claimed = await context.auth.claimedHeadscaleUserIds(); + const headscaleUsers = apiUsers.map((u) => ({ + id: u.id, + name: getUserDisplayName(u), + claimed: claimed.has(u.id), + })); + + // Build a map of Headscale user -> linked Headplane subject + const userLinks: Record = {}; + for (const u of apiUsers) { + const subject = getOidcSubject(u); + if (subject) { + const [hp] = await context.db + .select({ hsId: usersTable.headscale_user_id }) + .from(usersTable) + .where(eq(usersTable.sub, subject)) + .limit(1); + userLinks[u.id] = hp?.hsId ?? undefined; + } + } + return { writable: writablePermission, // whether the user can write to the API oidc: context.config.oidc @@ -84,6 +109,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { roles, magic, users, + headscaleUsers, + userLinks, }; } @@ -124,7 +151,13 @@ export default function Page({ loaderData }: Route.ComponentProps) { {users .sort((a, b) => a.name.localeCompare(b.name)) .map((user) => ( - + ))} diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 4f9a3f9..bb9a9a4 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -104,6 +104,33 @@ export async function userAction({ request, context }: Route.ActionArgs) { return { message: "User reassigned successfully" }; } + case "link_user": { + const userId = formData.get("user_id")?.toString(); + const headscaleUserId = formData.get("headscale_user_id")?.toString(); + if (!userId || !headscaleUserId) { + throw data("Missing `user_id` or `headscale_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 subject = getOidcSubject(user); + if (!subject) { + throw data("Specified user is not an OIDC user or has no subject.", { status: 400 }); + } + + const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId); + if (!linked) { + throw data("That Headscale user is already linked to another account.", { status: 409 }); + } + + return { message: "Headscale user linked successfully" }; + } default: throw data("Invalid `action_id` provided.", { status: 400, diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index cc88c9e..48bb0c9 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -339,6 +339,25 @@ export class AuthService { return true; } + /** + * Link a Headplane user (identified by OIDC subject) to a Headscale + * user. Used by admin UI when subjects are more accessible than + * internal Headplane IDs. Returns false if already claimed. + */ + async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise { + const [user] = await this.opts.db + .select({ id: users.id }) + .from(users) + .where(eq(users.sub, subject)) + .limit(1); + + if (!user) { + return false; + } + + return this.linkHeadscaleUser(user.id, headscaleUserId); + } + /** * Returns the set of Headscale user IDs that are already claimed * by a Headplane user. Used to filter the onboarding dropdown.