import { Check, Copy } from "lucide-react";
import { redirect } from "react-router";
import androidSvg from "~/assets/android.svg";
import iosSvg from "~/assets/ios.svg";
import linuxSvg from "~/assets/linux.svg";
import macosSvg from "~/assets/macos.svg";
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);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
try {
const users = await api.getUsers();
const hsUser = users.find((u) => u.id === principal.user.headscaleUserId);
linkedUserName = hsUser?.name;
} catch {
// API unavailable, skip linked user resolution
}
}
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 = [
{
href: "https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe",
icon: windowsSvg,
name: "Windows",
note: "Windows 10+",
},
{
href: "https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg",
icon: macosSvg,
name: "macOS",
note: "macOS Big Sur+",
},
{
href: "https://apps.apple.com/us/app/tailscale/id1470499037",
icon: iosSvg,
name: "iOS",
note: "iOS 15+",
},
{
href: "https://play.google.com/store/apps/details?id=com.tailscale.ipn",
icon: androidSvg,
name: "Android",
note: "Android 8+",
},
];
export default function Home({ loaderData }: Route.ComponentProps) {
if (loaderData.status === "needs_link") {
return
curl -fsSL https://tailscale.com/install.sh | sh
View script source