diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f27c6..4b0f6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)). - Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)). - Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information). +- Attempt to warn against misconfigured cookie settings on the login page. --- diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index 8ed5370..f0cfb0d 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -1,40 +1,35 @@ -import { useEffect } from 'react'; +import { AlertCircle } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { - ActionFunctionArgs, data, Form, - LoaderFunctionArgs, Link as RemixLink, redirect, - useActionData, - useLoaderData, useSearchParams, } from 'react-router'; import Button from '~/components/Button'; import Card from '~/components/Card'; import Code from '~/components/Code'; import Input from '~/components/Input'; -import type { LoadContext } from '~/server'; +import Link from '~/components/Link'; import { useLiveData } from '~/utils/live-data'; +import type { Route } from './+types/page'; import { loginAction } from './action'; import Logout from './logout'; -export async function loader({ - request, - context, -}: LoaderFunctionArgs) { +export async function loader({ request, context }: Route.LoaderArgs) { try { await context.sessions.auth(request); return redirect('/machines'); } catch {} const qp = new URL(request.url).searchParams; - const state = qp.get('s') ?? undefined; + const urlState = qp.get('s') ?? undefined; // OIDC config cannot be undefined if an OIDC client is set // Also check if we are in a logout state and skip redirect if we are const ssoOnly = context.config.oidc?.disable_api_key_login; - if (state !== 'logout' && ssoOnly) { + if (urlState !== 'logout' && ssoOnly) { // This shouldn't be possible, but still a safe sanity check if (!context.oidc) { throw data( @@ -49,30 +44,35 @@ export async function loader({ } return { - oidc: context.oidc, - state, + isOidcEnabled: context.oidc !== undefined, + isCookieSecureEnabled: context.config.server.cookie_secure, + urlState, }; } -export async function action(request: ActionFunctionArgs) { +export async function action(request: Route.ActionArgs) { return loginAction(request); } -export default function Page() { - const { state, oidc } = useLoaderData(); - const formData = useActionData(); +export default function Page({ loaderData, actionData }: Route.ComponentProps) { + const { isOidcEnabled, isCookieSecureEnabled, urlState } = loaderData; + const [showCookieWarning, setShowCookieWarning] = useState(false); const [params] = useSearchParams(); const { pause } = useLiveData(); useEffect(() => { // This page does NOT need stale while revalidate logic pause(); + + if (isCookieSecureEnabled && window.location.protocol !== 'https:') { + setShowCookieWarning(true); + } }); useEffect(() => { // State is a one time thing, we need to remove it after it has // been consumed to prevent logic loops. - if (state !== null) { + if (urlState !== null) { const searchParams = new URLSearchParams(params); searchParams.delete('s'); @@ -85,48 +85,71 @@ export default function Page() { window.history.replaceState(null, '', newUrl); } - }, [state, params]); + }, [urlState, params]); - if (state === 'logout') { + if (urlState === 'logout') { return ; } return (
- - Welcome to Headplane -
- - Enter an API key to authenticate with Headplane. You can generate - one by running headscale apikeys create in your - terminal. - - - {formData?.success === false ? ( - - {formData.message} +
+ {showCookieWarning ? ( + +
+ + Configuration Issue + + +
+ + Headplane is configured to use secure cookies, but this site is + being served over an insecure connection and login will not work + correctly.{' '} + + Learn more. + - ) : undefined} - - - {oidc ? ( - - - +
) : undefined} - + + Welcome to Headplane +
+ + Enter an API key to authenticate with Headplane. You can generate + one by running headscale apikeys create in your + terminal. + + + {actionData?.success === false ? ( + + {actionData.message} + + ) : undefined} + +
+ {isOidcEnabled ? ( + + + + ) : undefined} +
+
); } diff --git a/app/server/index.ts b/app/server/index.ts index 40a818c..f9b49ff 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -40,6 +40,12 @@ const agents = await createHeadplaneAgent( // These are usually per-request things that we need access to, like the // helper that can issue and revoke cookies. export type LoadContext = typeof appLoadContext; + +import 'react-router'; +declare module 'react-router' { + interface AppLoadContext extends LoadContext {} +} + const appLoadContext = { config, hs: await loadHeadscaleConfig( diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d9b508f..32991cf 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -25,7 +25,13 @@ export default defineConfig({ { text: 'Docker', link: '/install/docker' }, ], }, - { text: 'Configuration', link: '/configuration' }, + { + text: 'Configuration', + link: '/configuration', + items: [ + { text: 'Common Issues', link: '/configuration/common-issues' }, + ], + }, { text: 'Nix', link: '/Nix' }, { text: 'NixOS', link: '/NixOS-options' }, { diff --git a/docs/assets/login-banner-dark.png b/docs/assets/login-banner-dark.png new file mode 100644 index 0000000..ada0f81 Binary files /dev/null and b/docs/assets/login-banner-dark.png differ diff --git a/docs/assets/login-banner-light.png b/docs/assets/login-banner-light.png new file mode 100644 index 0000000..6679c37 Binary files /dev/null and b/docs/assets/login-banner-light.png differ diff --git a/docs/configuration/common-issues.md b/docs/configuration/common-issues.md new file mode 100644 index 0000000..3b741bb --- /dev/null +++ b/docs/configuration/common-issues.md @@ -0,0 +1,25 @@ +--- +title: Common Issues +description: Common issues and their solutions +--- + +# Common Issues and Their Solutions +This document outlines some common issues users may encounter while using Headplane, along with their solutions. + +## Login does not work +::: tip +Headplane tries to detect misconfigurations and will surface a warning banner on +the login page if it detects any abnormalities. You may see a banner like this: +
+ + +
Login Warning Banner
+
+::: + +If you attempt to log in to Headplane but nothing happens, it may be due to a +misconfiguration of the server cookie settings. In your Headplane configuration, +ensure that `server.cookie_secure` is set appropriately based on how you are +accessing Headplane: +- Serving over HTTPS: `cookie_secure` should be enabled (`true`). +- Serving over HTTP: `cookie_secure` should be disabled (`false`). diff --git a/docs/configuration.md b/docs/configuration/index.md similarity index 100% rename from docs/configuration.md rename to docs/configuration/index.md diff --git a/tsconfig.json b/tsconfig.json index 3fc47aa..4ae965a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,32 +1,26 @@ { "include": [ - "**/*.ts", - "**/*.tsx", - "**/.server/**/*.ts", - "**/.server/**/*.tsx", - "**/.client/**/*.ts", - "**/.client/**/*.tsx" + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" ], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["vite/client"], - "isolatedModules": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, + "types": ["node", "vite/client"], "target": "ES2022", - "strict": true, - "allowJs": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], "paths": { - "~/*": ["./app/*"], - "~server/*": ["./server/*"] + "~/*": ["./app/*"] }, - - // Vite takes care of building everything, not tsc. - "noEmit": true + "esModuleInterop": true, + "verbatimModuleSyntax": false, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true } }