feat: replace onboarding for explicit user linking

This commit is contained in:
Aarnav Tale
2026-03-14 13:23:22 -04:00
parent b7a85684a9
commit 38cf93e5ae
14 changed files with 409 additions and 606 deletions
+13 -9
View File
@@ -14,15 +14,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.onboarded &&
!request.url.endsWith("/onboarding")
) {
return redirect("/onboarding");
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
@@ -55,6 +46,19 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
});
}
}
// Self-heal: if the linked Headscale user was deleted, clear the
// stale link so the user gets prompted to re-link.
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
try {
const hsUsers = await api.getUsers();
if (!hsUsers.some((u) => u.id === principal.user.headscaleUserId)) {
await context.auth.unlinkHeadscaleUser(principal.user.id);
}
} catch {
// API call failed, skip validation
}
}
}
return {
+2 -4
View File
@@ -1,5 +1,5 @@
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
import { NavLink, useLocation, useSubmit } from "react-router";
import { NavLink, useSubmit } from "react-router";
import Link from "~/components/link";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
@@ -37,9 +37,7 @@ const tabs = [
export default function Header({ user, access, configAvailable }: HeaderProps) {
const submit = useSubmit();
const location = useLocation();
const isOnboarding = location.pathname.startsWith("/onboarding");
const showTabs = access.ui && !isOnboarding;
const showTabs = access.ui;
return (
<header
+49
View File
@@ -0,0 +1,49 @@
import { Form } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import cn from "~/utils/cn";
interface LinkAccountProps {
headscaleUsers: { id: string; name: string }[];
}
export default function LinkAccount({ headscaleUsers }: LinkAccountProps) {
return (
<div className="mx-auto mt-6 flex max-w-xl flex-col items-center justify-center py-36">
<Card variant="flat" className="max-w-xl items-center gap-4">
<Card.Title>Link your Headscale account</Card.Title>
<Card.Text>
Headplane could not automatically match your SSO identity to an existing Headscale user.
Please select your user from the list below to link your account and continue.
</Card.Text>
<Form method="POST" className="mt-4">
<select
className={cn(
"mb-4 w-full rounded-lg border p-2",
"border-mist-200 dark:border-mist-700",
"bg-mist-50 dark:bg-mist-900",
)}
name="headscale_user_id"
required
>
<option value="">Select a user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<Button className="w-full" type="submit" variant="heavy">
Link and Continue
</Button>
</Form>
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
If you don't see your user listed, please contact your administrator. To automatically
link new users in the future, ensure that the Headscale user has the same email address as
the SSO identity.
</Card.Text>
</Card>
</div>
);
}
-131
View File
@@ -1,131 +0,0 @@
import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer";
import Header from "~/layout/header";
import { Capabilities } from "~/server/web/roles";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/shell";
import NoAccess from "./no-access";
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.onboarded &&
!request.url.endsWith("/onboarding")
) {
return redirect("/onboarding");
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const check = context.auth.can(principal, Capabilities.ui_access);
const noAccess = !check && principal.kind === "oidc";
const user =
principal.kind === "oidc"
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
let linkedUserName: string | undefined;
let osValue: string | undefined;
if (noAccess && principal.kind === "oidc") {
const hsUserId = principal.user.headscaleUserId;
if (hsUserId) {
try {
const apiUsers = await api.getUsers();
const hsUser = apiUsers.find((u) => u.id === hsUserId);
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
} catch {
// API unavailable, skip linked user resolution
}
}
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
switch (os?.[0]) {
case "Windows": {
osValue = "windows";
break;
}
case "Mac OS X": {
osValue = "macos";
break;
}
case "iPhone":
case "iPad": {
osValue = "ios";
break;
}
case "Android": {
osValue = "android";
break;
}
default: {
osValue = "linux";
break;
}
}
}
return {
access: {
ui: check,
dns: context.auth.can(principal, Capabilities.read_network),
users: context.auth.can(principal, Capabilities.read_users),
policy: context.auth.can(principal, Capabilities.read_policy),
machines: context.auth.can(principal, Capabilities.read_machines),
settings: context.auth.can(principal, Capabilities.read_feature),
},
config: context.hs.c,
configAvailable: context.hs.readable(),
debug: context.config.debug,
healthy: await api.isHealthy(),
linkedUserName,
noAccess,
onboarding: request.url.endsWith("/onboarding"),
osValue,
url: context.config.headscale.public_url ?? context.config.headscale.url,
user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
export default function Shell({ loaderData }: Route.ComponentProps) {
if (loaderData.noAccess && !loaderData.onboarding) {
return (
<>
<Header user={loaderData.user} />
<NoAccess linkedUserName={loaderData.linkedUserName} osValue={loaderData.osValue} />
<Footer {...loaderData} />
</>
);
}
return (
<>
<Header user={loaderData.user} />
<Outlet />
<Footer {...loaderData} />
</>
);
}
-3
View File
@@ -17,9 +17,6 @@ export default [
// All the main logged-in routes
layout("layout/app.tsx", [
index("routes/home.tsx"),
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
+4 -1
View File
@@ -80,7 +80,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
})()
: userInfo.picture;
const userId = await context.auth.findOrCreateUser(claims.sub);
const userId = await context.auth.findOrCreateUser(claims.sub, {
name,
email: userInfo.email,
});
try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
+76 -7
View File
@@ -1,5 +1,5 @@
import { Check, Copy } from "lucide-react";
import { Form, redirect } from "react-router";
import { redirect } from "react-router";
import androidSvg from "~/assets/android.svg";
import iosSvg from "~/assets/ios.svg";
@@ -9,19 +9,59 @@ import windowsSvg from "~/assets/windows.svg";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
// If the OIDC user has no linked Headscale user, check for
// Unclaimed users they can pick from before anything else.
let unlinked = false;
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
let headscaleUsers: { id: string; name: string }[] = [];
try {
const [apiUsers, claimed] = await Promise.all([
api.getUsers(),
context.auth.claimedHeadscaleUserIds(),
]);
headscaleUsers = apiUsers
.filter((u) => !claimed.has(u.id))
.map((u) => ({ id: u.id, name: getUserDisplayName(u) }));
} catch {
// API unavailable, skip the link picker
}
if (headscaleUsers.length > 0) {
return { headscaleUsers, status: "needs_link" as const };
}
// No unclaimed users, fall through to no-access page.
// Only warn if Headscale isn't using OIDC — if it is, the user
// Just needs to connect a device and Headscale will auto-create
// Their account, at which point auto-link will pick it up.
if (!context.hs.c?.oidc) {
unlinked = true;
}
}
if (context.auth.can(principal, Capabilities.ui_access)) {
return redirect("/machines");
}
// No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
@@ -36,7 +76,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
}
return { linkedUserName };
return { linkedUserName, status: "no_access" as const, unlinked };
}
export async function action({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
return redirect("/");
}
const formData = await request.formData();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) {
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
}
return redirect("/");
}
const downloads = [
@@ -67,10 +123,14 @@ const downloads = [
];
export default function Home({ loaderData }: Route.ComponentProps) {
if (loaderData.status === "needs_link") {
return <LinkAccount headscaleUsers={loaderData.headscaleUsers} />;
}
return (
<div className="mx-auto mt-6 mb-24 flex max-w-2xl flex-col gap-4">
{loaderData.linkedUserName && (
<Card variant="raised" className="flex max-w-2xl items-center gap-4">
<Card variant="flat" className="flex max-w-2xl items-center gap-4">
<Check className="inline-flex size-4" />
<Card.Text className="text-sm">
Your account is linked to Headscale user <strong>{loaderData.linkedUserName}</strong>.
@@ -86,7 +146,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<div className="mt-4 rounded-lg border border-mist-200 p-3 dark:border-mist-700">
<div className="flex items-center gap-2">
<img alt="Linux" className="w-4" src={linuxSvg} />
<img alt="Linux" className="w-4 dark:invert" src={linuxSvg} />
<span className="text-sm font-medium">Linux</span>
</div>
<div className="mt-2 flex items-center gap-2">
@@ -138,14 +198,23 @@ export default function Home({ loaderData }: Route.ComponentProps) {
rel="noreferrer"
target="_blank"
>
<img alt={dl.name} className="h-6" src={dl.icon} />
<img alt={dl.name} className="h-6 dark:invert" src={dl.icon} />
<span className="text-sm font-medium">{dl.name}</span>
<span className="text-xs text-mist-500 dark:text-mist-400">{dl.note}</span>
</a>
))}
</div>
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
Need access to the dashboard? Contact your administrator to request access.
<Card.Text
className={cn(
"mt-8 text-center text-xs",
loaderData.unlinked
? "text-yellow-600 dark:text-yellow-400"
: "text-mist-600 dark:text-mist-300",
)}
>
{loaderData.unlinked
? "Your account isn't linked to a Headscale user. Ask your administrator to create one for you."
: "Need access to the dashboard? Contact your administrator to request access."}
</Card.Text>
</Card>
</div>
-52
View File
@@ -1,52 +0,0 @@
import { eq } from "drizzle-orm";
import { redirect } from "react-router";
import { users } from "~/server/db/schema";
import type { Route } from "./+types/onboarding-skip";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
return redirect("/machines");
}
await context.db
.update(users)
.set({ onboarded: true })
.where(eq(users.sub, principal.user.subject));
return redirect("/machines");
} catch {
return redirect("/login");
}
}
export async function action({ request, context }: Route.ActionArgs) {
try {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
return redirect("/machines");
}
const formData = await request.formData();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) {
const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
if (!linked) {
return redirect("/onboarding");
}
}
await context.db
.update(users)
.set({ onboarded: true })
.where(eq(users.sub, principal.user.subject));
return redirect("/machines");
} catch {
return redirect("/login");
}
}
-388
View File
@@ -1,388 +0,0 @@
import { Icon } from "@iconify/react";
import { ArrowRight } from "lucide-react";
import { useEffect } from "react";
import { Form, NavLink } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Link from "~/components/link";
import Options from "~/components/Options";
import StatusCircle from "~/components/StatusCircle";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Machine } from "~/types";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/onboarding";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
throw new Error("Onboarding is only available for OIDC users.");
}
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = "linux";
switch (os?.[0]) {
case "Windows": {
osValue = "windows";
break;
}
case "Mac OS X": {
osValue = "macos";
break;
}
case "iPhone":
case "iPad": {
osValue = "ios";
break;
}
case "Android": {
osValue = "android";
break;
}
default: {
osValue = "linux";
break;
}
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const hsUserId = principal.user.headscaleUserId;
let firstMachine: Machine | undefined;
let needsUserLink = false;
let linkedUserName: string | undefined;
let headscaleUsers: { id: string; name: string }[] = [];
try {
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
if (hsUserId) {
const hsUser = apiUsers.find((u) => u.id === hsUserId);
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
firstMachine = nodes.find((n) => n.user?.id === hsUserId);
} else {
const matched = findHeadscaleUserBySubject(
apiUsers,
principal.user.subject,
principal.profile.email,
);
if (matched) {
await context.auth.linkHeadscaleUser(principal.user.id, matched.id);
linkedUserName = getUserDisplayName(matched);
firstMachine = nodes.find((n) => n.user?.id === matched.id);
} else {
needsUserLink = true;
const claimed = await context.auth.claimedHeadscaleUserIds();
headscaleUsers = apiUsers
.filter((u) => !claimed.has(u.id))
.map((u) => ({
id: u.id,
name: getUserDisplayName(u),
}));
}
}
} catch (error) {
log.debug("api", "Failed to lookup nodes %o", error);
}
return {
firstMachine,
headscaleUsers,
linkedUserName,
needsUserLink,
osValue,
user: {
subject: principal.user.subject,
name: principal.profile.name,
email: principal.profile.email,
username: principal.profile.username,
picture: principal.profile.picture,
},
};
}
export default function Page({
loaderData: { user, osValue, firstMachine, needsUserLink, linkedUserName, headscaleUsers },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
pause();
} else {
resume();
}
}, [firstMachine]);
const subject = user.email ? (
<>
as <strong>{user.email}</strong>
</>
) : (
"with your OIDC provider"
);
return (
<div className="fixed flex h-screen w-full items-center px-4">
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
{needsUserLink ? (
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
<Card.Title className="mb-4">Link your Headscale account</Card.Title>
<Card.Text className="mb-4">
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."}
</Card.Text>
{headscaleUsers.length > 0 ? (
<Form method="POST" action="/onboarding/skip">
<select
className={cn(
"mb-4 w-full rounded-lg border p-2",
"border-mist-200 dark:border-mist-700",
"bg-mist-50 dark:bg-mist-900",
)}
name="headscale_user_id"
required
>
<option value="">Select a user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<Button className="w-full" type="submit" variant="heavy">
Link and Continue
</Button>
</Form>
) : undefined}
<NavLink className="mt-3 block text-center" to="/onboarding/skip">
<Button className="w-full" variant="light">
Skip — I'll do this later
</Button>
</NavLink>
<p className="mt-2 text-center text-xs text-mist-500">
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.
</p>
</Card>
) : undefined}
{linkedUserName && !needsUserLink ? (
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
<p className="text-sm">
✓ Your account has been linked to Headscale user <strong>{linkedUserName}</strong>.
</p>
</Card>
) : undefined}
<Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8">
Welcome!
<br />
Let's get set up
</Card.Title>
<Card.Text>
Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
automatically added to your Headscale network.
</Card.Text>
<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span>
</div>
}
>
<Button
className="text-md flex font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
"curl -fsSL https://tailscale.com/install.sh | sh",
);
toast("Copied to clipboard");
}}
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="mt-1 text-center text-xs text-mist-600 dark:text-mist-300">
Click this button to copy the command.{" "}
<Link
external
styled
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</Options.Item>
<Options.Item
key="windows"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{" "}
<Link external styled to="https://apps.apple.com/ca/app/tailscale/id1475387142">
macOS App Store
</Link>
.
</p>
</Options.Item>
<Options.Item
key="ios"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card variant="flat">
{firstMachine ? (
<div className="flex h-full flex-col justify-between">
<Card.Title className="mb-8">
Success!
<br />
We found your first device
</Card.Title>
<div className="rounded-xl border border-mist-100 p-4 dark:border-mist-800">
<div className="flex items-start gap-4">
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
<div>
<p className="leading-snug font-semibold">{firstMachine.givenName}</p>
<p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
<div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => (
<p className="font-mono text-xs opacity-50" key={ip}>
{ip}
</p>
))}
</div>
</div>
</div>
</div>
<NavLink to="/onboarding/skip">
<Button className="w-full" variant="heavy">
Continue
</Button>
</NavLink>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-4">
<span className="relative flex size-4">
<span
className={cn(
"absolute inline-flex h-full w-full",
"rounded-full opacity-75 animate-ping",
"bg-mist-500",
)}
/>
<span className={cn("relative inline-flex size-4 rounded-full", "bg-mist-400")} />
</span>
<p className="font-lg">Waiting for your first device...</p>
</div>
)}
</Card>
<NavLink className="col-span-2 mx-auto w-max" to="/onboarding/skip">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
}
+2 -1
View File
@@ -22,9 +22,10 @@ export type HostInfoInsert = typeof hostInfo.$inferInsert;
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
sub: text("sub").notNull().unique(),
name: text("name"),
email: text("email"),
role: text("role").notNull().default("member"),
headscale_user_id: text("headscale_user_id").unique(),
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
last_login_at: integer("last_login_at", { mode: "timestamp" }),
+28 -10
View File
@@ -29,7 +29,6 @@ export type Principal =
subject: string;
role: Role;
headscaleUserId: string | undefined;
onboarded: boolean;
};
profile: {
name: string;
@@ -149,10 +148,10 @@ export class AuthService {
subject: user.sub,
role,
headscaleUserId: user.headscale_user_id ?? undefined,
onboarded: user.onboarded,
},
profile: payload.profile ?? {
name: user.sub,
name: user.name ?? user.sub,
email: user.email ?? undefined,
},
};
}
@@ -234,7 +233,6 @@ export class AuthService {
* Get the Headscale API key for making API calls.
* OIDC sessions use the configured oidc.headscale_api_key.
* API key sessions use the user-provided key stored in the cookie.
* TODO: Get rid of this AI garbage
*/
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
if (principal.kind === "api_key") {
@@ -275,9 +273,13 @@ export class AuthService {
/**
* Find or create a Headplane user by OIDC subject. Returns the
* user ID. The first user ever created is automatically granted
* the owner role (bootstrap). Uses upsert to avoid race conditions.
* the owner role (bootstrap). Profile data (name, email) is
* refreshed on every login.
*/
async findOrCreateUser(subject: string): Promise<string> {
async findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string },
): Promise<string> {
const [existing] = await this.opts.db
.select()
.from(users)
@@ -287,7 +289,12 @@ export class AuthService {
if (existing) {
await this.opts.db
.update(users)
.set({ last_login_at: new Date(), updated_at: new Date() })
.set({
name: profile?.name,
email: profile?.email,
last_login_at: new Date(),
updated_at: new Date(),
})
.where(eq(users.id, existing.id));
return existing.id;
}
@@ -296,9 +303,10 @@ export class AuthService {
await this.opts.db.insert(users).values({
id,
sub: subject,
name: profile?.name,
email: profile?.email,
role: "member",
caps: capsForRole("member"),
onboarded: false,
});
// If this is the only user in the table, promote to owner.
@@ -340,6 +348,17 @@ export class AuthService {
return true;
}
/**
* Clear the Headscale user link for a Headplane user. Used when the
* linked Headscale user no longer exists.
*/
async unlinkHeadscaleUser(userId: string): Promise<void> {
await this.opts.db
.update(users)
.set({ headscale_user_id: null, updated_at: new Date() })
.where(eq(users.id, userId));
}
/**
* Link a Headplane user (identified by OIDC subject) to a Headscale
* user. Used by admin UI when subjects are more accessible than
@@ -361,7 +380,7 @@ export class AuthService {
/**
* Returns the set of Headscale user IDs that are already claimed
* by a Headplane user. Used to filter the onboarding dropdown.
* by a Headplane user. Used to filter the link picker.
*/
async claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
@@ -406,7 +425,6 @@ export class AuthService {
sub: subject,
role,
caps: capsForRole(role),
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE `users` ADD `name` text;--> statement-breakpoint
ALTER TABLE `users` ADD `email` text;--> statement-breakpoint
ALTER TABLE `users` DROP COLUMN `onboarded`;
+225
View File
@@ -0,0 +1,225 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6d6838a7-e7ab-4661-bc7d-b47566bfff13",
"prevId": "e397c1d9-19a4-494a-9b87-5a94a093286a",
"tables": {
"auth_sessions": {
"name": "auth_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_key_hash": {
"name": "api_key_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_key_display": {
"name": "api_key_display",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ephemeral_nodes": {
"name": "ephemeral_nodes",
"columns": {
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"node_key": {
"name": "node_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"host_info": {
"name": "host_info",
"columns": {
"host_id": {
"name": "host_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"sub": {
"name": "sub",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'member'"
},
"headscale_user_id": {
"name": "headscale_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caps": {
"name": "caps",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_sub_unique": {
"name": "users_sub_unique",
"columns": ["sub"],
"isUnique": true
},
"users_headscale_user_id_unique": {
"name": "users_headscale_user_id_unique",
"columns": ["headscale_user_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -29,6 +29,13 @@
"when": 1772917638504,
"tag": "0003_thick_otto_octavius",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1773505790202,
"tag": "0004_nappy_praxagora",
"breakpoints": true
}
]
}