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>
);
}