mirror of
https://github.com/tale/headplane.git
synced 2026-09-04 11:15:42 +00:00
feat: redo user page to match account linking
This commit is contained in:
+183
-119
@@ -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<string, User>();
|
||||
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<string, string | undefined> = {};
|
||||
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<UserMachine[]>(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 (
|
||||
<>
|
||||
<h1 className="mb-1.5 text-2xl font-medium">Users</h1>
|
||||
<p className="text-md mb-8">Manage the users in your network and their permissions.</p>
|
||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||
<thead className="text-mist-600 dark:text-mist-300">
|
||||
<tr className="px-0.5 text-left">
|
||||
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Role</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
|
||||
<th className="w-12 pb-2">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
|
||||
"border-t border-mist-100 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
currentLink={loaderData.userLinks[user.id]}
|
||||
headscaleUsers={loaderData.headscaleUsers}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{loaderData.apiError && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-6 flex items-start gap-3 rounded-lg border p-4",
|
||||
"border-red-200 bg-red-50 text-red-800",
|
||||
"dark:border-red-800 dark:bg-red-950 dark:text-red-200",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm">{loaderData.apiError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-medium">Headplane Users</h2>
|
||||
{loaderData.headplaneUsers.length === 0 ? (
|
||||
<p className="text-sm text-mist-600 dark:text-mist-300">
|
||||
No users have signed into Headplane yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||
<thead className="text-mist-600 dark:text-mist-300">
|
||||
<tr className="px-0.5 text-left">
|
||||
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Role</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Last Login</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Status</th>
|
||||
<th className="w-12 pb-2">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
|
||||
"border-t border-mist-100 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
{loaderData.headplaneUsers.map((user) => (
|
||||
<HeadplaneUserRow
|
||||
key={user.id}
|
||||
headscaleUsers={loaderData.headscaleUsersForLink}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!loaderData.apiError && loaderData.unlinkedHeadscaleUsers.length > 0 && (
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-1 text-lg font-medium">Unlinked Headscale Users</h2>
|
||||
<p className="mb-3 text-sm text-mist-600 dark:text-mist-300">
|
||||
These Headscale users are not linked to a Headplane account and cannot be managed
|
||||
through Headplane.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||
<thead className="text-mist-600 dark:text-mist-300">
|
||||
<tr className="px-0.5 text-left">
|
||||
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Status</th>
|
||||
<th className="w-12 pb-2">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
|
||||
"border-t border-mist-100 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
{loaderData.unlinkedHeadscaleUsers.map((user) => (
|
||||
<HeadscaleUserRow key={user.id} user={user} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user