mirror of
https://github.com/tale/headplane.git
synced 2026-08-24 19:46:29 +00:00
feat: replace onboarding for explicit user linking
This commit is contained in:
+13
-9
@@ -14,15 +14,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
|||||||
try {
|
try {
|
||||||
const principal = await context.auth.require(request);
|
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 apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
const api = context.hsApi.getRuntimeClient(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 {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
|
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 Link from "~/components/link";
|
||||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||||
@@ -37,9 +37,7 @@ const tabs = [
|
|||||||
|
|
||||||
export default function Header({ user, access, configAvailable }: HeaderProps) {
|
export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||||
const submit = useSubmit();
|
const submit = useSubmit();
|
||||||
const location = useLocation();
|
const showTabs = access.ui;
|
||||||
const isOnboarding = location.pathname.startsWith("/onboarding");
|
|
||||||
const showTabs = access.ui && !isOnboarding;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,9 +17,6 @@ export default [
|
|||||||
// All the main logged-in routes
|
// All the main logged-in routes
|
||||||
layout("layout/app.tsx", [
|
layout("layout/app.tsx", [
|
||||||
index("routes/home.tsx"),
|
index("routes/home.tsx"),
|
||||||
route("/onboarding", "routes/users/onboarding.tsx"),
|
|
||||||
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
|
|
||||||
|
|
||||||
...prefix("/machines", [
|
...prefix("/machines", [
|
||||||
index("routes/machines/overview.tsx"),
|
index("routes/machines/overview.tsx"),
|
||||||
route("/:id", "routes/machines/machine.tsx"),
|
route("/:id", "routes/machines/machine.tsx"),
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
})()
|
})()
|
||||||
: userInfo.picture;
|
: userInfo.picture;
|
||||||
|
|
||||||
const userId = await context.auth.findOrCreateUser(claims.sub);
|
const userId = await context.auth.findOrCreateUser(claims.sub, {
|
||||||
|
name,
|
||||||
|
email: userInfo.email,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
||||||
|
|||||||
+76
-7
@@ -1,5 +1,5 @@
|
|||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { Form, redirect } from "react-router";
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
import androidSvg from "~/assets/android.svg";
|
import androidSvg from "~/assets/android.svg";
|
||||||
import iosSvg from "~/assets/ios.svg";
|
import iosSvg from "~/assets/ios.svg";
|
||||||
@@ -9,19 +9,59 @@ import windowsSvg from "~/assets/windows.svg";
|
|||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Card from "~/components/Card";
|
import Card from "~/components/Card";
|
||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
|
import LinkAccount from "~/layout/link-account";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import cn from "~/utils/cn";
|
import cn from "~/utils/cn";
|
||||||
import toast from "~/utils/toast";
|
import toast from "~/utils/toast";
|
||||||
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
|
|
||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const principal = await context.auth.require(request);
|
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)) {
|
if (context.auth.can(principal, Capabilities.ui_access)) {
|
||||||
return redirect("/machines");
|
return redirect("/machines");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No UI access — show the download/connect page
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
const api = context.hsApi.getRuntimeClient(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 = [
|
const downloads = [
|
||||||
@@ -67,10 +123,14 @@ const downloads = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
|
if (loaderData.status === "needs_link") {
|
||||||
|
return <LinkAccount headscaleUsers={loaderData.headscaleUsers} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-6 mb-24 flex max-w-2xl flex-col gap-4">
|
<div className="mx-auto mt-6 mb-24 flex max-w-2xl flex-col gap-4">
|
||||||
{loaderData.linkedUserName && (
|
{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" />
|
<Check className="inline-flex size-4" />
|
||||||
<Card.Text className="text-sm">
|
<Card.Text className="text-sm">
|
||||||
Your account is linked to Headscale user <strong>{loaderData.linkedUserName}</strong>.
|
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="mt-4 rounded-lg border border-mist-200 p-3 dark:border-mist-700">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-sm font-medium">Linux</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
@@ -138,14 +198,23 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
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-sm font-medium">{dl.name}</span>
|
||||||
<span className="text-xs text-mist-500 dark:text-mist-400">{dl.note}</span>
|
<span className="text-xs text-mist-500 dark:text-mist-400">{dl.note}</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
|
<Card.Text
|
||||||
Need access to the dashboard? Contact your administrator to request access.
|
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.Text>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -22,9 +22,10 @@ export type HostInfoInsert = typeof hostInfo.$inferInsert;
|
|||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable("users", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
sub: text("sub").notNull().unique(),
|
sub: text("sub").notNull().unique(),
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email"),
|
||||||
role: text("role").notNull().default("member"),
|
role: text("role").notNull().default("member"),
|
||||||
headscale_user_id: text("headscale_user_id").unique(),
|
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()),
|
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||||
updated_at: integer("updated_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" }),
|
last_login_at: integer("last_login_at", { mode: "timestamp" }),
|
||||||
|
|||||||
+28
-10
@@ -29,7 +29,6 @@ export type Principal =
|
|||||||
subject: string;
|
subject: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
headscaleUserId: string | undefined;
|
headscaleUserId: string | undefined;
|
||||||
onboarded: boolean;
|
|
||||||
};
|
};
|
||||||
profile: {
|
profile: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -149,10 +148,10 @@ export class AuthService {
|
|||||||
subject: user.sub,
|
subject: user.sub,
|
||||||
role,
|
role,
|
||||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||||
onboarded: user.onboarded,
|
|
||||||
},
|
},
|
||||||
profile: payload.profile ?? {
|
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.
|
* Get the Headscale API key for making API calls.
|
||||||
* OIDC sessions use the configured oidc.headscale_api_key.
|
* OIDC sessions use the configured oidc.headscale_api_key.
|
||||||
* API key sessions use the user-provided key stored in the cookie.
|
* 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 {
|
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
|
||||||
if (principal.kind === "api_key") {
|
if (principal.kind === "api_key") {
|
||||||
@@ -275,9 +273,13 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Find or create a Headplane user by OIDC subject. Returns the
|
* Find or create a Headplane user by OIDC subject. Returns the
|
||||||
* user ID. The first user ever created is automatically granted
|
* 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
|
const [existing] = await this.opts.db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
@@ -287,7 +289,12 @@ export class AuthService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await this.opts.db
|
await this.opts.db
|
||||||
.update(users)
|
.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));
|
.where(eq(users.id, existing.id));
|
||||||
return existing.id;
|
return existing.id;
|
||||||
}
|
}
|
||||||
@@ -296,9 +303,10 @@ export class AuthService {
|
|||||||
await this.opts.db.insert(users).values({
|
await this.opts.db.insert(users).values({
|
||||||
id,
|
id,
|
||||||
sub: subject,
|
sub: subject,
|
||||||
|
name: profile?.name,
|
||||||
|
email: profile?.email,
|
||||||
role: "member",
|
role: "member",
|
||||||
caps: capsForRole("member"),
|
caps: capsForRole("member"),
|
||||||
onboarded: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// If this is the only user in the table, promote to owner.
|
// If this is the only user in the table, promote to owner.
|
||||||
@@ -340,6 +348,17 @@ export class AuthService {
|
|||||||
return true;
|
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
|
* Link a Headplane user (identified by OIDC subject) to a Headscale
|
||||||
* user. Used by admin UI when subjects are more accessible than
|
* 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
|
* 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>> {
|
async claimedHeadscaleUserIds(): Promise<Set<string>> {
|
||||||
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
|
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
|
||||||
@@ -406,7 +425,6 @@ export class AuthService {
|
|||||||
sub: subject,
|
sub: subject,
|
||||||
role,
|
role,
|
||||||
caps: capsForRole(role),
|
caps: capsForRole(role),
|
||||||
onboarded: false,
|
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: users.sub,
|
target: users.sub,
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1772917638504,
|
"when": 1772917638504,
|
||||||
"tag": "0003_thick_otto_octavius",
|
"tag": "0003_thick_otto_octavius",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773505790202,
|
||||||
|
"tag": "0004_nappy_praxagora",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user