diff --git a/app/routes/users/components/headplane-user-row.tsx b/app/routes/users/components/headplane-user-row.tsx new file mode 100644 index 0000000..e38c92b --- /dev/null +++ b/app/routes/users/components/headplane-user-row.tsx @@ -0,0 +1,95 @@ +import { CircleUser } from "lucide-react"; + +import StatusCircle from "~/components/StatusCircle"; +import type { Role } from "~/server/web/roles"; +import cn from "~/utils/cn"; + +import type { HeadplaneUserData } from "../overview"; +import MenuOptions from "./menu"; + +interface HeadplaneUserRowProps { + user: HeadplaneUserData; + headscaleUsers: { id: string; name: string; claimed: boolean }[]; +} + +export default function HeadplaneUserRow({ user, headscaleUsers }: HeadplaneUserRowProps) { + const isOnline = user.machines.some((machine) => machine.online); + const lastSeen = user.machines.reduce( + (acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()), + 0, + ); + + const displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub; + const displayEmail = user.linkedHeadscaleUser?.email ?? user.email; + + return ( + + +
+ {user.profilePicUrl ? ( + {displayName} + ) : ( + + )} +
+

{displayName}

+ {displayEmail &&

{displayEmail}

} + {!user.headscaleUserId && ( +

Not linked

+ )} +
+
+ + +

{mapRoleToName(user.role)}

+ + +

+ {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : "Never"} +

+ + + {user.machines.length > 0 ? ( + + +

+ {isOnline ? "Connected" : new Date(lastSeen).toLocaleString()} +

+
+ ) : ( +

No machines

+ )} + + + + + + ); +} + +function mapRoleToName(role: Role) { + switch (role) { + case "owner": + return "Owner"; + case "admin": + return "Admin"; + case "network_admin": + return "Network Admin"; + case "it_admin": + return "IT Admin"; + case "auditor": + return "Auditor"; + case "viewer": + return "Viewer"; + case "member": + return

Member

; + default: + return "Unknown"; + } +} diff --git a/app/routes/users/components/headscale-user-row.tsx b/app/routes/users/components/headscale-user-row.tsx new file mode 100644 index 0000000..164d54d --- /dev/null +++ b/app/routes/users/components/headscale-user-row.tsx @@ -0,0 +1,62 @@ +import { CircleUser } from "lucide-react"; + +import StatusCircle from "~/components/StatusCircle"; +import cn from "~/utils/cn"; + +import type { UnlinkedHeadscaleUser } from "../overview"; + +interface HeadscaleUserRowProps { + user: UnlinkedHeadscaleUser; +} + +export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) { + const isOnline = user.machines.some((machine) => machine.online); + const lastSeen = user.machines.reduce( + (acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()), + 0, + ); + + return ( + + +
+ {user.profilePicUrl ? ( + {user.name + ) : ( + + )} +
+

{user.name || user.displayName}

+ {user.email &&

{user.email}

} +
+
+ + +

+ {new Date(user.createdAt).toLocaleDateString()} +

+ + + {user.machines.length > 0 ? ( + + +

+ {isOnline ? "Connected" : new Date(lastSeen).toLocaleString()} +

+
+ ) : ( +

No machines

+ )} + + + {/* Unlinked users only get basic Headscale operations (rename, delete) */} + + + ); +} diff --git a/app/routes/users/components/manage-banner.tsx b/app/routes/users/components/manage-banner.tsx index a2e2894..1f6ca34 100644 --- a/app/routes/users/components/manage-banner.tsx +++ b/app/routes/users/components/manage-banner.tsx @@ -1,7 +1,7 @@ -import { Building2, House, Key } from "lucide-react"; +import { Building2, House } from "lucide-react"; -import Card from "~/components/Card"; import Link from "~/components/link"; +import cn from "~/utils/cn"; import CreateUser from "../dialogs/create-user"; @@ -12,44 +12,34 @@ interface ManageBannerProps { export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) { return ( - -
-
- {oidc ? : } -

{oidc ? "OpenID Connect" : "User Authentication"}

-

- {oidc ? ( - <> - Users are managed through your{" "} - - OpenID Connect provider - - {". "} - Groups and user information do not automatically sync.{" "} - Learn more - - ) : ( - <> - Users are not managed externally. Using OpenID Connect can create a better - experience when using Headscale.{" "} - Learn more - - )} -

-
-
- -

User Management

-

- {oidc - ? "You can still add users manually, however it is recommended that you manage users through your OIDC provider." - : "You can add, remove, and rename users here."} -

-
- -
-
+
+
+ {oidc ? : } +

+ {oidc ? ( + <> + Users are managed through your{" "} + + OIDC provider + + . + + ) : ( + <> + Users are managed locally.{" "} + + Set up OIDC + + + )} +

- + +
); } diff --git a/app/routes/users/components/menu.tsx b/app/routes/users/components/menu.tsx index 14f3261..6e3820b 100644 --- a/app/routes/users/components/menu.tsx +++ b/app/routes/users/components/menu.tsx @@ -2,75 +2,67 @@ import { Ellipsis } from "lucide-react"; import { useState } from "react"; import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu"; -import type { Machine, User } from "~/types"; import Delete from "../dialogs/delete-user"; import LinkUser from "../dialogs/link-user"; import Reassign from "../dialogs/reassign-user"; -import Rename from "../dialogs/rename-user"; +import type { HeadplaneUserData } from "../overview"; interface MenuProps { - user: User & { - headplaneRole: string; - machines: Machine[]; - }; + user: HeadplaneUserData; headscaleUsers: { id: string; name: string; claimed: boolean }[]; currentLink?: string; } -type Modal = "rename" | "delete" | "reassign" | "link" | null; +type Modal = "delete" | "reassign" | "link" | null; export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) { const [modal, setModal] = useState(null); + const isLinked = currentLink !== undefined; const disabledKeys: string[] = []; - if (user.provider === "oidc") { - disabledKeys.push("rename"); - } else { - disabledKeys.push("reassign", "link"); + if (!isLinked) { + disabledKeys.push("reassign"); } // Filter linkable users: unclaimed, or the one currently linked to this user const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink); + const displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub; + return ( <> - {modal === "rename" && ( - { - if (!isOpen) setModal(null); - }} - user={user} - /> - )} - {modal === "delete" && ( + {modal === "delete" && user.linkedHeadscaleUser && ( { if (!isOpen) setModal(null); }} - user={user} + user={user.linkedHeadscaleUser} /> )} {modal === "reassign" && ( { if (!isOpen) setModal(null); }} - user={user} + userId={user.linkedHeadscaleUser?.id ?? user.id} /> )} {modal === "link" && ( { if (!isOpen) setModal(null); }} - user={user} + userId={user.linkedHeadscaleUser?.id ?? user.id} /> )} @@ -79,22 +71,23 @@ export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProp - setModal("rename")}> - Rename user - setModal("reassign")} > Change role - setModal("link")}> - Link Headscale user - - - setModal("delete")}> - Delete + setModal("link")}> + {isLinked ? "Change linked user" : "Link Headscale user"} + {user.linkedHeadscaleUser && ( + <> + + setModal("delete")}> + Delete + + + )} diff --git a/app/routes/users/components/user-row.tsx b/app/routes/users/components/user-row.tsx deleted file mode 100644 index 2438f9c..0000000 --- a/app/routes/users/components/user-row.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { CircleUser } from "lucide-react"; - -import StatusCircle from "~/components/StatusCircle"; -import { Machine, User } from "~/types"; -import cn from "~/utils/cn"; - -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, 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()), - 0, - ); - - return ( - - -
- {user.profilePicUrl ? ( - {user.name - ) : ( - - )} -
-

{user.name || user.displayName}

-

{user.email}

-
-
- - -

{mapRoleToName(role)}

- - -

- {new Date(user.createdAt).toLocaleDateString()} -

- - - - -

- {isOnline ? "Connected" : new Date(lastSeen).toLocaleString()} -

-
- - - - - - ); -} - -function mapRoleToName(role: string) { - switch (role) { - case "no-oidc": - return

Unmanaged

; - case "invalid-oidc": - return

Invalid

; - case "no-role": - return

Unregistered

; - case "owner": - return "Owner"; - case "admin": - return "Admin"; - case "network_admin": - return "Network Admin"; - case "it_admin": - return "IT Admin"; - case "auditor": - return "Auditor"; - case "member": - return

No Access

; - default: - return "Unknown"; - } -} diff --git a/app/routes/users/dialogs/create-user.tsx b/app/routes/users/dialogs/create-user.tsx index f7d53ca..a5e29f8 100644 --- a/app/routes/users/dialogs/create-user.tsx +++ b/app/routes/users/dialogs/create-user.tsx @@ -1,66 +1,60 @@ -import Dialog from '~/components/Dialog'; -import Input from '~/components/Input'; +import Dialog from "~/components/Dialog"; +import Input from "~/components/Input"; interface CreateUserProps { - isOidc?: boolean; - isDisabled?: boolean; + isOidc?: boolean; + isDisabled?: boolean; } -// TODO: Support image upload for user avatars export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) { - return ( - - Add a new user - - Add a new user - - Enter a username to create a new user. Usernames can be addressed when - managing ACL policies. - {isOidc ? ( - <> - {' '} - Manually created users are given administrative access to - Headplane unless they become linked to an OIDC user in Headscale. - - ) : undefined} - - -
- { - if (value.trim().length === 0) { - return 'Username is required'; - } + return ( + + Add user + + Create a Headscale user + + This creates a new user in Headscale. The user will appear in the “Unlinked + Headscale Users” section until they sign in + {isOidc ? " through your OIDC provider" : ""} and are automatically linked to a Headplane + account. + + +
+ { + if (value.trim().length === 0) { + return "Username is required"; + } - if (value.includes(' ')) { - return 'Usernames cannot contain spaces'; - } + if (value.includes(" ")) { + return "Usernames cannot contain spaces"; + } - return true; - }} - validationBehavior="native" - /> - - -
-
-
- ); + return true; + }} + validationBehavior="native" + /> + + +
+
+
+ ); } diff --git a/app/routes/users/dialogs/delete-user.tsx b/app/routes/users/dialogs/delete-user.tsx index 14d2825..1db2082 100644 --- a/app/routes/users/dialogs/delete-user.tsx +++ b/app/routes/users/dialogs/delete-user.tsx @@ -1,20 +1,21 @@ import Dialog from "~/components/Dialog"; -import { Machine, User } from "~/types"; +import type { Machine, User } from "~/types"; interface DeleteProps { - user: User & { machines: Machine[] }; + user: User; + machines: Machine[]; isOpen: boolean; setIsOpen: (isOpen: boolean) => void; } -export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) { +export default function DeleteUser({ user, machines, isOpen, setIsOpen }: DeleteProps) { const name = user.name || user.displayName; return ( - 0 ? "unactionable" : "normal"}> + 0 ? "unactionable" : "normal"}> Delete {name}? - {user.machines.length > 0 ? ( + {machines.length > 0 ? ( Users cannot be deleted if they have machines. Please delete or re-assign their machines to other users before proceeding. diff --git a/app/routes/users/dialogs/link-user.tsx b/app/routes/users/dialogs/link-user.tsx index 822a1ce..7d18713 100644 --- a/app/routes/users/dialogs/link-user.tsx +++ b/app/routes/users/dialogs/link-user.tsx @@ -1,10 +1,10 @@ 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 }; + userId: string; + displayName: string; headscaleUsers: { id: string; name: string }[]; currentLink?: string; isOpen: boolean; @@ -12,7 +12,8 @@ interface LinkUserProps { } export default function LinkUser({ - user, + userId, + displayName, headscaleUsers, currentLink, isOpen, @@ -21,9 +22,9 @@ export default function LinkUser({ return ( - Link Headscale user for {user.name || user.displayName} + Link Headscale user for {displayName} - Select which Headscale user this OIDC identity should be linked to. This controls which + Select which Headscale user this identity should be linked to. This controls which machines they can manage and enables self-service features. {headscaleUsers.length === 0 ? ( @@ -31,7 +32,7 @@ export default function LinkUser({ ) : ( <> - + - + {Object.keys(Roles) - .filter((role) => role !== "owner") - .map((role) => { - const { name, desc } = mapRoleToName(role); + .filter((r) => r !== "owner") + .map((r) => { + const { name, desc } = mapRoleToName(r); return ( - +

{name}

{desc}

@@ -80,6 +87,11 @@ function mapRoleToName(role: string) { name: "Auditor", desc: "Can view the admin console.", }; + case "viewer": + return { + name: "Viewer", + desc: "Can view machines, users, and generate their own auth keys.", + }; case "member": return { name: "Member", @@ -87,8 +99,8 @@ function mapRoleToName(role: string) { }; default: return { - name: "Unknown", - desc: "Unknown", + name: role, + desc: "No description available.", }; } } diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index cb7388b..d944b1a 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,22 +1,35 @@ import { createHash } from "node:crypto"; -import { eq } from "drizzle-orm"; -import { useEffect, useState } from "react"; - import PageError from "~/components/page-error"; -import { users as usersTable } from "~/server/db/schema"; -import { getOidcSubject } from "~/server/web/headscale-identity"; -import { Capabilities } from "~/server/web/roles"; +import { Capabilities, Roles } from "~/server/web/roles"; +import type { Role } from "~/server/web/roles"; import type { Machine, User } from "~/types"; import cn from "~/utils/cn"; +import log from "~/utils/log"; import { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/overview"; +import HeadplaneUserRow from "./components/headplane-user-row"; +import HeadscaleUserRow from "./components/headscale-user-row"; import ManageBanner from "./components/manage-banner"; -import UserRow from "./components/user-row"; import { userAction } from "./user-actions"; -interface UserMachine extends User { +export interface HeadplaneUserData { + id: string; + sub: string; + name: string | null; + email: string | null; + role: Role; + headscaleUserId: string | null; + createdAt: Date | null; + lastLoginAt: Date | null; + // Enriched from Headscale API (may be absent if API failed) + linkedHeadscaleUser?: User; + machines: Machine[]; + profilePicUrl?: string; +} + +export interface UnlinkedHeadscaleUser extends User { machines: Machine[]; } @@ -24,7 +37,6 @@ export async function loader({ request, context }: Route.LoaderArgs) { const principal = await context.auth.require(request); const check = await context.auth.can(principal, Capabilities.read_users); if (!check) { - // Not authorized to view this page throw new Error( "You do not have permission to view this page. Please contact your administrator.", ); @@ -32,45 +44,81 @@ export async function loader({ request, context }: Route.LoaderArgs) { const writablePermission = await context.auth.can(principal, Capabilities.write_users); - const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); - const api = context.hsApi.getRuntimeClient(apiKey); - const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); + // Primary data: Headplane users from the database (always available) + const hpUsers = await context.auth.listUsers(); - const users = apiUsers.map((user) => ({ - ...user, - machines: nodes.filter((node) => node.user?.id === user.id), - profilePicUrl: - context.config.oidc?.profile_picture_source === "gravatar" - ? (() => { - if (!user.email) { - return undefined; - } + // Secondary data: Headscale API (may fail) + let apiUsers: User[] = []; + let nodes: Machine[] = []; + let apiError: string | undefined; - const emailHash = user.email.trim().toLowerCase(); - const hash = createHash("sha256").update(emailHash).digest("hex"); - return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; - })() - : user.profilePicUrl, + try { + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); + } catch (error) { + log.warn("api", "Failed to fetch Headscale API data: %s", String(error)); + apiError = + "Could not connect to the Headscale API. Headscale user data and machine information are unavailable."; + } + + const useGravatar = context.config.oidc?.profile_picture_source === "gravatar"; + + function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined { + if (!useGravatar) return profilePicUrl; + if (!email) return undefined; + const hash = createHash("sha256").update(email.trim().toLowerCase()).digest("hex"); + return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; + } + + // Build a lookup from Headscale user ID → Headscale user + const hsUserMap = new Map(); + for (const u of apiUsers) { + hsUserMap.set(u.id, u); + } + + // Build the primary user list: Headplane users enriched with Headscale data + const headplaneUsers: HeadplaneUserData[] = hpUsers + .sort((a, b) => (a.name ?? a.sub).localeCompare(b.name ?? b.sub)) + .map((hp) => { + const hsUser = hp.headscale_user_id ? hsUserMap.get(hp.headscale_user_id) : undefined; + const machines = hsUser ? nodes.filter((n) => n.user?.id === hsUser.id) : []; + + return { + id: hp.id, + sub: hp.sub, + name: hp.name, + email: hp.email, + role: (hp.role in Roles ? hp.role : "member") as Role, + headscaleUserId: hp.headscale_user_id, + createdAt: hp.created_at, + lastLoginAt: hp.last_login_at, + linkedHeadscaleUser: hsUser, + machines, + profilePicUrl: hsUser + ? resolveProfilePic(hsUser.email, hsUser.profilePicUrl) + : resolveProfilePic(hp.email ?? undefined), + }; + }); + + // Build the unlinked Headscale users list + const claimedIds = new Set(hpUsers.map((u) => u.headscale_user_id).filter(Boolean)); + const unlinkedHeadscaleUsers: UnlinkedHeadscaleUser[] = apiUsers + .filter((u) => !claimedIds.has(u.id)) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((u) => ({ + ...u, + machines: nodes.filter((n) => n.user?.id === u.id), + profilePicUrl: resolveProfilePic(u.email, u.profilePicUrl), + })); + + // Build linkable Headscale users for admin link dialog + const headscaleUsersForLink = apiUsers.map((u) => ({ + id: u.id, + name: getUserDisplayName(u), + claimed: claimedIds.has(u.id), })); - const roles = await Promise.all( - users - .sort((a, b) => a.name.localeCompare(b.name)) - .map(async (user) => { - if (user.provider !== "oidc") { - return "no-oidc"; - } - - const subject = getOidcSubject(user); - if (!subject) { - return "invalid-oidc"; - } - - const role = await context.auth.roleForSubject(subject); - return role ?? "no-role"; - }), - ); - let magic: string | undefined; if (context.hs.readable()) { if (context.hs.c?.dns.magic_dns) { @@ -78,94 +126,110 @@ 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 - ? { - issuer: context.config.oidc.issuer, - } - : undefined, - roles, + writable: writablePermission, + oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined, magic, - users, - headscaleUsers, - userLinks, + apiError, + headplaneUsers, + unlinkedHeadscaleUsers, + headscaleUsersForLink, }; } export const action = userAction; export default function Page({ loaderData }: Route.ComponentProps) { - const [users, setUsers] = useState(loaderData.users); - - // This useEffect is entirely for the purpose of updating the users when the - // drag and drop changes the machines between users. It's pretty hacky, but - // the idea is to treat data.users as the source of truth and update the - // local state when it changes. - useEffect(() => { - setUsers(loaderData.users); - }, [loaderData.users]); - return ( <>

Users

Manage the users in your network and their permissions.

-
- - - - - - - - - - - - {users - .sort((a, b) => a.name.localeCompare(b.name)) - .map((user) => ( - - ))} - -
UserRoleCreated AtLast Seen - Actions -
-
+ + {loaderData.apiError && ( +
+

{loaderData.apiError}

+
+ )} + +
+

Headplane Users

+ {loaderData.headplaneUsers.length === 0 ? ( +

+ No users have signed into Headplane yet. +

+ ) : ( +
+ + + + + + + + + + + + {loaderData.headplaneUsers.map((user) => ( + + ))} + +
UserRoleLast LoginStatus + Actions +
+
+ )} +
+ + {!loaderData.apiError && loaderData.unlinkedHeadscaleUsers.length > 0 && ( +
+

Unlinked Headscale Users

+

+ These Headscale users are not linked to a Headplane account and cannot be managed + through Headplane. +

+
+ + + + + + + + + + + {loaderData.unlinkedHeadscaleUsers.map((user) => ( + + ))} + +
UserCreated AtStatus + Actions +
+
+
+ )} ); } diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index 62a2558..7da8f74 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -7,7 +7,7 @@ import { ulid } from "ulidx"; import type { Machine } from "~/types"; -import { authSessions, users } from "../db/schema"; +import { type HeadplaneUser, authSessions, users } from "../db/schema"; import { Capabilities, type Role, Roles, capsForRole } from "./roles"; // ── Principal ──────────────────────────────────────────────────────── @@ -378,6 +378,14 @@ export class AuthService { return this.linkHeadscaleUser(user.id, headscaleUserId); } + /** + * List all Headplane user records. Used by the users overview page + * to display the primary user list independently of the Headscale API. + */ + async listUsers(): Promise { + return this.opts.db.select().from(users); + } + /** * Returns the set of Headscale user IDs that are already claimed * by a Headplane user. Used to filter the link picker. @@ -408,6 +416,24 @@ export class AuthService { return (user.role in Roles ? user.role : "member") as Role; } + /** + * Get the role for a Headplane user linked to a given Headscale user ID. + * Returns undefined if no Headplane user is linked to this Headscale user. + */ + async roleForHeadscaleUser(headscaleUserId: string): Promise { + const [user] = await this.opts.db + .select() + .from(users) + .where(eq(users.headscale_user_id, headscaleUserId)) + .limit(1); + + if (!user) { + return; + } + + return (user.role in Roles ? user.role : "member") as Role; + } + /** * Reassign the role of a user identified by their OIDC subject. * Cannot reassign the owner role.