diff --git a/app/layout/app.tsx b/app/layout/app.tsx index 5ba68af..06f8b0f 100644 --- a/app/layout/app.tsx +++ b/app/layout/app.tsx @@ -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 { diff --git a/app/layout/header.tsx b/app/layout/header.tsx index b1db09f..237aeaa 100644 --- a/app/layout/header.tsx +++ b/app/layout/header.tsx @@ -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 (
+ + Link your Headscale account + + 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. + +
+ + +
+ + 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. + +
+ + ); +} diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx deleted file mode 100644 index 79a77b5..0000000 --- a/app/layouts/shell.tsx +++ /dev/null @@ -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 ( - <> -
- -