diff --git a/app/assets/android.svg b/app/assets/android.svg new file mode 100644 index 0000000..b7a0596 --- /dev/null +++ b/app/assets/android.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/ios.svg b/app/assets/ios.svg new file mode 100644 index 0000000..091454a --- /dev/null +++ b/app/assets/ios.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/linux.svg b/app/assets/linux.svg new file mode 100644 index 0000000..f4bf4c0 --- /dev/null +++ b/app/assets/linux.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/macos.svg b/app/assets/macos.svg new file mode 100644 index 0000000..7237dfe --- /dev/null +++ b/app/assets/macos.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/windows.svg b/app/assets/windows.svg new file mode 100644 index 0000000..d7ff703 --- /dev/null +++ b/app/assets/windows.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 78258f2..a743549 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -20,7 +20,7 @@ export default function Button({ variant = "light", ...props }: ButtonProps) { ref={ref} {...buttonProps} className={cn( - "w-fit rounded-lg px-3.5 py-2 text-sm leading-tight", + "w-fit rounded-md px-3.5 py-2 text-sm leading-tight", "transition-colors duration-100", "focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1", "dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900", diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx index 158e8a0..98dc1fb 100644 --- a/app/components/Dialog.tsx +++ b/app/components/Dialog.tsx @@ -15,7 +15,6 @@ import { } from "react-stately"; import Button, { ButtonProps } from "~/components/Button"; -import Card from "~/components/Card"; import IconButton, { IconButtonProps } from "~/components/IconButton"; import Text from "~/components/Text"; import Title from "~/components/Title"; @@ -101,7 +100,13 @@ function Panel(props: DialogPanelProps) { return (
{ if (onSubmit) { @@ -112,25 +117,23 @@ function Panel(props: DialogPanelProps) { }} ref={ref} > - - {children} -
- {variant === "unactionable" ? ( - - ) : ( - <> - - - - )} -
-
+
{children}
+
+ {variant === "unactionable" ? ( + + ) : ( + <> + + + + )} +
); } @@ -155,9 +158,9 @@ function DModal(props: DModalProps) { {...underlayProps} aria-hidden="true" className={cn( - "fixed inset-0 h-screen w-screen z-20", + "fixed inset-0 z-20 h-screen w-screen", "flex items-center justify-center", - "bg-mist-900/15 dark:bg-mist-900/30", + "bg-mist-900/30 dark:bg-mist-950/60", "entering:animate-in exiting:animate-out", "entering:fade-in entering:duration-100 entering:ease-out", "exiting:fade-out exiting:duration-50 exiting:ease-in", @@ -165,7 +168,10 @@ function DModal(props: DModalProps) { />
{children}
diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx deleted file mode 100644 index 29a6417..0000000 --- a/app/components/Footer.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { CircleX } from "lucide-react"; - -import Link from "~/components/link"; -import cn from "~/utils/cn"; - -interface FooterProps { - url: string; - debug: boolean; - healthy: boolean; -} - -export default function Footer({ url, debug, healthy }: FooterProps) { - return ( - - ); -} diff --git a/app/components/Header.tsx b/app/components/Header.tsx deleted file mode 100644 index 23c5db5..0000000 --- a/app/components/Header.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { CircleUser, Globe2, Lock, Server, Settings, Users } from "lucide-react"; -import type { ReactNode } from "react"; -import { NavLink, useSubmit } from "react-router"; - -import Logo from "~/components/Logo"; -import Menu from "~/components/Menu"; -import { AuthSession } from "~/server/web/sessions"; -import cn from "~/utils/cn"; - -interface Props { - configAvailable: boolean; - onboarding: boolean; - user?: AuthSession["user"]; - access: { - ui: boolean; - machines: boolean; - dns: boolean; - users: boolean; - policy: boolean; - settings: boolean; - }; -} - -interface LinkProps { - href: string; - text: string; -} - -interface TabLinkProps { - name: string; - to: string; - icon: ReactNode; -} - -function TabLink({ name, to, icon }: TabLinkProps) { - return ( -
- - cn( - "px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5", - "after:absolute after:bottom-0 after:left-3 after:right-3", - "after:h-0.5 after:bg-mist-900 dark:after:bg-mist-200", - "hover:bg-mist-200 dark:hover:bg-mist-900", - "focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1", - "dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900", - isActive ? "after:visible" : "after:invisible", - ) - } - prefetch="intent" - to={to} - > - {icon} {name} - -
- ); -} - -function Link({ href, text }: LinkProps) { - return ( - - {text} - - ); -} - -export default function Header(data: Props) { - const submit = useSubmit(); - - return ( -
-
-
- -

headplane

-
-
- - - - {data.user ? ( - - - {data.user.picture ? ( - {data.user.name} - ) : ( - - )} - - { - if (key === "logout") { - submit( - {}, - { - method: "POST", - action: "/logout", - }, - ); - } - }} - > - - -
-

{data.user.name}

-

{data.user.email}

-
-
- -

Logout

-
-
-
-
- ) : undefined} -
-
- {data.access.ui && !data.onboarding ? ( - - ) : undefined} -
- ); -} diff --git a/app/components/Logo.tsx b/app/components/Logo.tsx deleted file mode 100644 index cb44372..0000000 --- a/app/components/Logo.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import cn from '~/utils/cn'; -import LogoSvg from '../../public/logo-light.svg'; - -export interface LogoProps { - className?: string; -} - -export default function Logo({ className }: LogoProps) { - return Logo; -} diff --git a/app/components/error-banner.tsx b/app/components/error-banner.tsx index cf9fd84..d096627 100644 --- a/app/components/error-banner.tsx +++ b/app/components/error-banner.tsx @@ -17,7 +17,6 @@ export function getErrorMessage(error: Error | unknown): { const { statusCode, rawData, data, requestUrl } = error.data; if (statusCode >= 500) { return { - title: "Headscale API Error", jsxMessage: ( <> @@ -37,13 +36,13 @@ export function getErrorMessage(error: Error | unknown): { )} ), + title: "Headscale API Error", }; } const authError = error.data.statusCode === 401 || error.data.statusCode === 403; return { - title: "Invalid response from Headscale API", jsxMessage: ( <> @@ -84,13 +83,13 @@ export function getErrorMessage(error: Error | unknown): { ), + title: "Invalid response from Headscale API", }; } if (isConnectionError(error.data)) { const { requestUrl, errorCode, errorMessage, extraData } = error.data; return { - title: "Cannot connect to Headscale API", jsxMessage: ( <> @@ -112,11 +111,11 @@ export function getErrorMessage(error: Error | unknown): { ), + title: "Cannot connect to Headscale API", }; } return { - title: `Error ${error.status}`, jsxMessage: ( <> There was an error processing your request. @@ -126,18 +125,18 @@ export function getErrorMessage(error: Error | unknown): { Status Text: {error.data} ), + title: `Error ${error.status}`, }; } if (!(error instanceof Error)) { return { - title: "Unexpected Error", jsxMessage: ( <> An unexpected error occurred which is most likely a bug. Please consider reporting filing an issue on the{" "} - + Headplane GitHub {" "} repository with the details below. @@ -148,6 +147,7 @@ export function getErrorMessage(error: Error | unknown): { ), + title: "Unexpected Error", }; } @@ -166,11 +166,11 @@ export function getErrorMessage(error: Error | unknown): { } return { + jsxMessage: rootError.message, title: rootError.name.length > 0 && rootError.name !== "Error" ? `Error: ${rootError.name}` : "Error", - jsxMessage: rootError.message, }; } diff --git a/app/components/link.tsx b/app/components/link.tsx index 2517c03..b8496cf 100644 --- a/app/components/link.tsx +++ b/app/components/link.tsx @@ -1,51 +1,48 @@ import { ExternalLink } from "lucide-react"; -import type { JSX } from "react"; +import type { JSX, ReactNode } from "react"; import { Link as RouterLink } from "react-router"; import cn from "~/utils/cn"; -const linkStyles = cn( - "inline-flex items-center gap-x-0.5", - "text-blue-500 hover:text-blue-700", - "dark:text-blue-400 dark:hover:text-blue-300", - "focus:ring-offset-1 rounded-md", - "focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40", - "dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900", -); - export type LinkProps = | { - isExternal: true; + external: true; to: string; - name: string; - children: string; + children: ReactNode; className?: string; + styled?: boolean; } | { - isExternal?: false; + external?: false; to: string; - children?: string; + children?: ReactNode; className?: string; }; export default function Link(props: LinkProps): JSX.Element { - if (props.isExternal) { + if (props.external) { return ( {props.children} - + {props.styled && } ); } return ( - + {props.children} ); diff --git a/app/components/Menu.tsx b/app/components/menu-old.tsx similarity index 100% rename from app/components/Menu.tsx rename to app/components/menu-old.tsx diff --git a/app/components/menu.tsx b/app/components/menu.tsx new file mode 100644 index 0000000..5368903 --- /dev/null +++ b/app/components/menu.tsx @@ -0,0 +1,103 @@ +// oxlint-disable react/no-multi-comp +import { Menu as BaseMenu } from "@base-ui/react/menu"; +import type { ComponentProps, JSX, ReactNode } from "react"; + +import cn from "~/utils/cn"; + +const SIDE_OFFSET = 8; + +export const Menu = ({ + children, + disabled, +}: { + children: ReactNode; + disabled?: boolean; +}): JSX.Element => {children}; + +export const MenuTrigger = ({ + className, + children, + disabled, + onClick, +}: Pick< + ComponentProps, + "className" | "children" | "disabled" | "onClick" +>): JSX.Element => ( + + {children} + +); + +export const MenuContent = ({ + children, + className, + side = "bottom", + sideOffset = SIDE_OFFSET, + align = "start", +}: { + children: ReactNode; + className?: string; + side?: ComponentProps["side"]; + sideOffset?: number; + align?: ComponentProps["align"]; +}): JSX.Element => ( + + + + {children} + + + +); + +export const MenuItem = ({ + className, + variant, + children, + disabled, + onClick, +}: Pick, "className" | "children" | "disabled" | "onClick"> & { + variant?: "danger"; +}): JSX.Element => ( + + {children} + +); + +export const MenuSeparator = ({ className }: { className?: string }): JSX.Element => ( + +); diff --git a/app/layout/app.tsx b/app/layout/app.tsx new file mode 100644 index 0000000..5ba68af --- /dev/null +++ b/app/layout/app.tsx @@ -0,0 +1,106 @@ +import { Outlet, redirect } from "react-router"; + +import { ErrorBanner } from "~/components/error-banner"; +import { pruneEphemeralNodes } from "~/server/db/pruner"; +import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; +import { Capabilities } from "~/server/web/roles"; +import log from "~/utils/log"; + +import type { Route } from "./+types/app"; +import Footer from "./footer"; +import Header from "./header"; + +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); + + 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" }; + + // MARK: The session should stay valid if Headscale isn't healthy + const isHealthy = await api.isHealthy(); + if (isHealthy) { + try { + await api.getApiKeys(); + await pruneEphemeralNodes({ context, request, ...rest }); + } catch (error) { + if (isDataUnauthorizedError(error)) { + const displayName = + principal.kind === "oidc" ? principal.profile.name : principal.displayName; + log.warn("auth", "Logging out %s due to expired API key", displayName); + return redirect("/login", { + headers: { + "Set-Cookie": await context.auth.destroySession(request), + }, + }); + } + } + } + + return { + access: { + dns: context.auth.can(principal, Capabilities.read_network), + machines: context.auth.can(principal, Capabilities.read_machines), + policy: context.auth.can(principal, Capabilities.read_policy), + settings: context.auth.can(principal, Capabilities.read_feature), + ui: context.auth.can(principal, Capabilities.ui_access), + users: context.auth.can(principal, Capabilities.read_users), + }, + baseUrl: context.config.headscale.public_url ?? context.config.headscale.url, + configAvailable: context.hs.readable(), + isDebug: context.config.debug, + isHealthy, + user, + }; + } catch { + return redirect("/login", { + headers: { + "Set-Cookie": await context.auth.destroySession(request), + }, + }); + } +} + +export default function AppLayout({ loaderData }: Route.ComponentProps) { + return ( + <> +
+
+ +
+