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}
>
+
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 (
-
+
- {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