diff --git a/authorize/evaluator/evaluator.go b/authorize/evaluator/evaluator.go index e6316de6c..1c71c0b6d 100644 --- a/authorize/evaluator/evaluator.go +++ b/authorize/evaluator/evaluator.go @@ -241,6 +241,7 @@ var internalPathsNeedingLogin = set.From([]string{ "/.pomerium/jwt", "/.pomerium/user", "/.pomerium/webauthn", + "/.pomerium/routes", "/.pomerium/api/v1/routes", }) diff --git a/proxy/handlers.go b/proxy/handlers.go index 6e9411ac8..3b0b19ec4 100644 --- a/proxy/handlers.go +++ b/proxy/handlers.go @@ -30,6 +30,7 @@ func (p *Proxy) registerDashboardHandlers(r *mux.Router, opts *config.Options) * if opts.IsRuntimeFlagSet(config.RuntimeFlagPomeriumJWTEndpoint) { h.Path("/jwt").Handler(httputil.HandlerFunc(p.jwtAssertion)).Methods(http.MethodGet) } + h.Path("/routes").Handler(httputil.HandlerFunc(p.routesPortalHTML)).Methods(http.MethodGet) h.Path("/sign_out").Handler(httputil.HandlerFunc(p.SignOut)).Methods(http.MethodGet, http.MethodPost) h.Path("/user").Handler(httputil.HandlerFunc(p.jsonUserInfo)).Methods(http.MethodGet) h.Path("/webauthn").Handler(p.webauthn) diff --git a/proxy/handlers_portal.go b/proxy/handlers_portal.go index 7d28aed06..cfe94593e 100644 --- a/proxy/handlers_portal.go +++ b/proxy/handlers_portal.go @@ -8,8 +8,17 @@ import ( "github.com/pomerium/pomerium/internal/handlers" "github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/proxy/portal" + "github.com/pomerium/pomerium/ui" ) +func (p *Proxy) routesPortalHTML(w http.ResponseWriter, r *http.Request) error { + u := p.getUserInfoData(r) + rs := p.getPortalRoutes(u) + m := u.ToJSON() + m["routes"] = rs + return ui.ServePage(w, r, "Routes", "Routes Portal", m) +} + func (p *Proxy) routesPortalJSON(w http.ResponseWriter, r *http.Request) error { u := p.getUserInfoData(r) rs := p.getPortalRoutes(u) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0b1e287a4..3bb667f30 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,15 +4,16 @@ import React, { FC, useLayoutEffect } from "react"; import ErrorPage from "./components/ErrorPage"; import Footer from "./components/Footer"; import Header from "./components/Header"; +import RoutesPage from "./components/RoutesPage"; import SignOutConfirmPage from "./components/SignOutConfirmPage"; import SignedOutPage from "./components/SignedOutPage"; import { ToolbarOffset } from "./components/ToolbarOffset"; +import UpstreamErrorPage from "./components/UpstreamErrorPage"; import UserInfoPage from "./components/UserInfoPage"; import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage"; import { SubpageContextProvider } from "./context/Subpage"; import { createTheme } from "./theme"; import { PageData } from "./types"; -import UpstreamErrorPage from "./components/UpstreamErrorPage"; const App: FC = () => { const data = (window["POMERIUM_DATA"] || {}) as PageData; @@ -20,8 +21,11 @@ const App: FC = () => { const secondary = data?.secondaryColor || "#49AAA1"; const theme = createTheme(primary, secondary); let body: React.ReactNode = <>; - if(data?.page === 'Error' && data?.statusText?.toLowerCase().includes('upstream')) { - data.page = 'UpstreamError'; + if ( + data?.page === "Error" && + data?.statusText?.toLowerCase().includes("upstream") + ) { + data.page = "UpstreamError"; } switch (data?.page) { case "UpstreamError": @@ -30,6 +34,9 @@ const App: FC = () => { case "Error": body = ; break; + case "Routes": + body = ; + break; case "SignOutConfirm": body = ; break; @@ -64,7 +71,10 @@ const App: FC = () => { -
+
= ({ includeSidebar, data }) => { setDrawerOpen(false); }; + const handleRoutes = (evt: React.MouseEvent): void => { + evt.preventDefault(); + window.open("/.pomerium/routes"); + }; + const handleUserInfo = (evt: React.MouseEvent): void => { evt.preventDefault(); window.open("/.pomerium/"); @@ -145,6 +150,7 @@ const Header: FC = ({ includeSidebar, data }) => { anchorEl={anchorEl} > User Info + Routes Logout diff --git a/ui/src/components/RoutesPage.tsx b/ui/src/components/RoutesPage.tsx new file mode 100644 index 000000000..11c9aef1f --- /dev/null +++ b/ui/src/components/RoutesPage.tsx @@ -0,0 +1,145 @@ +import { + Avatar, + Box, + Card, + CardActionArea, + CardContent, + CardHeader, + Grid, + IconButton, + Paper, + Snackbar, + Stack, + Typography, +} from "@mui/material"; +import React, { FC, useState } from "react"; +import { Clipboard, Link } from "react-feather"; + +import { Route, RoutesPageData } from "../types"; +import Section from "./Section"; +import SidebarPage from "./SidebarPage"; + +type RouteCardProps = { + route: Route; +}; +const RouteCard: FC = ({ route }) => { + const [showSnackbar, setShowSnackbar] = useState(false); + + const handleClick = (evt: React.MouseEvent) => { + if (route.connect_command) { + evt.preventDefault(); + navigator.clipboard.writeText(route.connect_command); + setShowSnackbar(true); + } + }; + + return ( + + + + ) : route.type === "tcp" ? ( + TCP + ) : route.type === "udp" ? ( + UDP + ) : ( + + + + ) + } + action={ + route.connect_command && ( + + + + ) + } + title={route.name} + /> + + {route.description && ( + {route.description} + )} + {route.connect_command && ( + + {route.connect_command} + + )} + + + setShowSnackbar(false)} + message="Copied to Clipboard" + /> + + ); +}; + +type RoutesSectionProps = { + type: "http" | "tcp" | "udp"; + title: string; + allRoutes: Route[]; +}; +const RoutesSection: FC = ({ type, title, allRoutes }) => { + const routes = allRoutes?.filter((r) => r.type === type); + if (routes?.length === 0) { + return <>; + } + + return ( +
+ + {routes?.map((r) => ( + + + + ))} + +
+ ); +}; + +type RoutesPageProps = { + data: RoutesPageData; +}; +const RoutesPage: FC = ({ data }) => { + return ( + + + {data?.routes ? ( + <> + + + + + ) : ( + + No accessible routes found + + )} + + + ); +}; +export default RoutesPage; diff --git a/ui/src/components/SidebarPage.tsx b/ui/src/components/SidebarPage.tsx new file mode 100644 index 000000000..66ac37452 --- /dev/null +++ b/ui/src/components/SidebarPage.tsx @@ -0,0 +1,44 @@ +import { Box, Container, Drawer, useMediaQuery, useTheme } from "@mui/material"; +import React, { FC } from "react"; + +import { ToolbarOffset } from "./ToolbarOffset"; +import UserSidebarContent from "./UserSidebarContent"; + +const SidebarPage: FC = ({ children }) => { + const theme = useTheme(); + const mdUp = useMediaQuery(() => theme.breakpoints.up("md"), { + defaultMatches: true, + noSsr: false, + }); + + return ( + + {mdUp && ( + + + + + + )} + + {children} + + + ); +}; +export default SidebarPage; diff --git a/ui/src/components/UserInfoPage.tsx b/ui/src/components/UserInfoPage.tsx index aa90b6f5d..8e24714dd 100644 --- a/ui/src/components/UserInfoPage.tsx +++ b/ui/src/components/UserInfoPage.tsx @@ -1,15 +1,11 @@ import { Button, - Container, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, - Drawer, Stack, - useMediaQuery, - useTheme, } from "@mui/material"; import React, { FC, useContext, useEffect, useState } from "react"; @@ -18,18 +14,12 @@ import { UserInfoData } from "../types"; import GroupDetails from "./GroupDetails"; import SessionDetails from "./SessionDetails"; import SessionDeviceCredentials from "./SessionDeviceCredentials"; -import { ToolbarOffset } from "./ToolbarOffset"; -import { UserSidebarContent } from "./UserSidebarContent"; +import SidebarPage from "./SidebarPage"; type UserInfoPageProps = { data: UserInfoData & { page: "DeviceEnrolled" | "UserInfo" }; }; const UserInfoPage: FC = ({ data }) => { - const theme = useTheme(); - const mdUp = useMediaQuery(() => theme.breakpoints.up("md"), { - defaultMatches: true, - noSsr: false, - }); const { subpage } = useContext(SubpageContext); const [showDeviceEnrolled, setShowDeviceEnrolled] = useState(false); @@ -47,7 +37,7 @@ const UserInfoPage: FC = ({ data }) => { } return ( - + Device Enrolled @@ -57,30 +47,7 @@ const UserInfoPage: FC = ({ data }) => { - {mdUp && ( - - - - - - )} - + {subpage === "User" && ( )} @@ -103,7 +70,7 @@ const UserInfoPage: FC = ({ data }) => { /> )} - + ); }; export default UserInfoPage; diff --git a/ui/src/components/UserSidebarContent.tsx b/ui/src/components/UserSidebarContent.tsx index 2aa57a527..a9682f46b 100644 --- a/ui/src/components/UserSidebarContent.tsx +++ b/ui/src/components/UserSidebarContent.tsx @@ -6,27 +6,36 @@ import { ListItemText, } from "@mui/material"; import React, { FC, ReactNode, useContext } from "react"; -import { User, Users } from "react-feather"; +import { Link, User, Users } from "react-feather"; import { SubpageContext } from "../context/Subpage"; export interface Subpage { icon: ReactNode; title: string; + pathname: string; } export const sectionList: Subpage[] = [ { title: "User", icon: , + pathname: "/.pomerium/", }, { title: "Groups Info", icon: , + pathname: "/.pomerium/", }, { title: "Devices Info", icon: , + pathname: "/.pomerium/", + }, + { + title: "Routes", + icon: , + pathname: "/.pomerium/routes", }, ]; type UserSidebarContent = { @@ -39,12 +48,17 @@ export const UserSidebarContent: FC = ({ return ( - {sectionList.map(({ title, icon }) => { + {sectionList.map(({ title, icon, pathname }) => { return ( { + if (location.pathname !== pathname) { + location.href = + pathname + "#subpage=" + encodeURIComponent(title); + return; + } info.setSubpage(title); !!close && close(); }} diff --git a/ui/src/context/Subpage.tsx b/ui/src/context/Subpage.tsx index be8692f46..de0114282 100644 --- a/ui/src/context/Subpage.tsx +++ b/ui/src/context/Subpage.tsx @@ -18,11 +18,22 @@ export const SubpageContextProvider: FC = ({ children, }) => { const setSubpage = (subpage: string) => { + location.hash = "subpage=" + encodeURIComponent(subpage); setState({ ...state, subpage }); }; + const hashParams = new URLSearchParams(location.hash.substring(1)); const initState = { - subpage: page === "DeviceEnrolled" ? "Devices Info" : "User", + subpage: + page === "DeviceEnrolled" + ? "Devices Info" + : page === "Routes" + ? "Routes" + : hashParams.get("subpage") === "Groups Info" + ? "Groups Info" + : hashParams.get("subpage") === "Devices Info" + ? "Devices Info" + : "User", setSubpage, }; diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index a4fe02f98..7cf993596 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -109,6 +109,22 @@ export type DeviceEnrolledPageData = BasePageData & page: "DeviceEnrolled"; }; +export type Route = { + id: string; + name: string; + type: "http" | "tcp" | "udp"; + from: string; + connect_command?: string; + description: string; + logo_url: string; +}; + +export type RoutesPageData = BasePageData & + UserInfoData & { + page: "Routes"; + routes: Route[]; + }; + export type SignOutConfirmPageData = BasePageData & { page: "SignOutConfirm"; url: string; @@ -135,6 +151,7 @@ export type WebAuthnRegistrationPageData = BasePageData & { export type PageData = | ErrorPageData | DeviceEnrolledPageData + | RoutesPageData | SignOutConfirmPageData | SignedOutPageData | UserInfoPageData