mirror of
https://github.com/pomerium/pomerium.git
synced 2025-07-17 16:48:13 +02:00
frontend: react+mui (#3004)
* mui v5 wip * wip * wip * wip * use compressor for all controlplane endpoints * wip * wip * add deps * fix authenticate URL * fix test * fix test * fix build * maybe fix build * fix integration test * remove image asset test * add yarn.lock
This commit is contained in:
parent
64d8748251
commit
2824faecbf
84 changed files with 13373 additions and 1455 deletions
30
ui/src/components/AlertDialog.tsx
Normal file
30
ui/src/components/AlertDialog.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import Alert, { AlertColor } from "@mui/material/Alert";
|
||||
import Dialog, { DialogProps } from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type AlertDialogProps = DialogProps & {
|
||||
title?: React.ReactNode;
|
||||
severity?: AlertColor;
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
export const AlertDialog: FC<AlertDialogProps> = ({
|
||||
title,
|
||||
severity,
|
||||
children,
|
||||
actions,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Dialog transitionDuration={{ exit: 0 }} {...props}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity={severity || "info"}>{children}</Alert>
|
||||
</DialogContent>
|
||||
{actions ? <DialogActions>{actions}</DialogActions> : <></>}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default AlertDialog;
|
24
ui/src/components/ClaimValue.tsx
Normal file
24
ui/src/components/ClaimValue.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import IDField from "./IDField";
|
||||
import { DateTime } from "luxon";
|
||||
import React, { FC } from "react";
|
||||
|
||||
const unixSecondTimestampFields = new Set(["exp", "iat", "nbf", "auth_time"]);
|
||||
|
||||
const idFields = new Set(["groups", "jti", "oid", "tid", "wids"]);
|
||||
|
||||
type ClaimValueProps = {
|
||||
claimKey: string;
|
||||
claimValue: unknown;
|
||||
};
|
||||
const ClaimValue: FC<ClaimValueProps> = ({ claimKey, claimValue }) => {
|
||||
if (unixSecondTimestampFields.has(claimKey)) {
|
||||
return <>{DateTime.fromMillis((claimValue as number) * 1000).toISO()}</>;
|
||||
}
|
||||
|
||||
if (idFields.has(claimKey)) {
|
||||
return <IDField value={`${claimValue}`} />;
|
||||
}
|
||||
|
||||
return <>{`${claimValue}`}</>;
|
||||
};
|
||||
export default ClaimValue;
|
57
ui/src/components/ClaimsTable.tsx
Normal file
57
ui/src/components/ClaimsTable.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Claims } from "../types";
|
||||
import ClaimValue from "./ClaimValue";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import React, { FC } from "react";
|
||||
|
||||
type ClaimsTableProps = {
|
||||
claims: Claims;
|
||||
};
|
||||
const ClaimsTable: FC<ClaimsTableProps> = ({ claims }) => {
|
||||
const entries = Object.entries(claims || {});
|
||||
entries.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell variant="head">Claims</TableCell>
|
||||
<TableCell variant="head"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{entries.length > 0 ? (
|
||||
entries.map(([key, values]) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell>{key}</TableCell>
|
||||
<TableCell>
|
||||
{values?.map((v, i) => (
|
||||
<React.Fragment key={`${v}`}>
|
||||
{i > 0 ? <br /> : <></>}
|
||||
<ClaimValue claimKey={key} claimValue={v} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} padding="none">
|
||||
<Alert severity="warning" square={true}>
|
||||
No Claims Found
|
||||
</Alert>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
export default ClaimsTable;
|
9
ui/src/components/CsrfInput.tsx
Normal file
9
ui/src/components/CsrfInput.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React, { FC } from "react";
|
||||
|
||||
export type CsrfInputProps = {
|
||||
csrfToken: string;
|
||||
};
|
||||
export const CsrfInput: FC<CsrfInputProps> = ({ csrfToken }) => {
|
||||
return <input type="hidden" name="_pomerium_csrf" value={csrfToken} />;
|
||||
};
|
||||
export default CsrfInput;
|
72
ui/src/components/DeviceCredentialsTable.tsx
Normal file
72
ui/src/components/DeviceCredentialsTable.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import IDField from "./IDField";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type DeviceCredentialsTableProps = {
|
||||
csrfToken: string;
|
||||
ids: string[];
|
||||
webAuthnUrl: string;
|
||||
};
|
||||
export const DeviceCredentialsTable: FC<DeviceCredentialsTableProps> = ({
|
||||
csrfToken,
|
||||
ids,
|
||||
webAuthnUrl
|
||||
}) => {
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ids?.length > 0 ? (
|
||||
ids?.map((id) => (
|
||||
<TableRow key={id}>
|
||||
<TableCell>
|
||||
<IDField value={id} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<form action={webAuthnUrl} method="POST">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_pomerium_csrf"
|
||||
value={csrfToken}
|
||||
/>
|
||||
<input type="hidden" name="action" value="unregister" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="pomerium_device_credential_id"
|
||||
value={id}
|
||||
/>
|
||||
<Button size="small" type="submit" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} padding="none">
|
||||
<Alert severity="warning" square>
|
||||
No device credentials found.
|
||||
</Alert>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
export default DeviceCredentialsTable;
|
19
ui/src/components/DeviceEnrolledPage.tsx
Normal file
19
ui/src/components/DeviceEnrolledPage.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import HeroSection from "./HeroSection";
|
||||
import Container from "@mui/material/Container";
|
||||
import React, { FC } from "react";
|
||||
import { DeviceEnrolledPageData } from "src/types";
|
||||
|
||||
type DeviceEnrolledPageProps = {
|
||||
data: DeviceEnrolledPageData;
|
||||
};
|
||||
const DeviceEnrolledPage: FC<DeviceEnrolledPageProps> = () => {
|
||||
return (
|
||||
<Container>
|
||||
<HeroSection
|
||||
title="Device Enrolled"
|
||||
text="Device Successfully Enrolled"
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
export default DeviceEnrolledPage;
|
44
ui/src/components/ErrorPage.tsx
Normal file
44
ui/src/components/ErrorPage.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { ErrorPageData } from "../types";
|
||||
import SectionFooter from "./SectionFooter";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import Box from "@mui/material/Box";
|
||||
import Container from "@mui/material/Container";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type ErrorPageProps = {
|
||||
data: ErrorPageData;
|
||||
};
|
||||
export const ErrorPage: FC<ErrorPageProps> = ({ data }) => {
|
||||
return (
|
||||
<Container>
|
||||
<Paper sx={{ overflow: "hidden" }}>
|
||||
<Stack>
|
||||
<Box sx={{ padding: "16px" }}>
|
||||
<Alert severity="error">
|
||||
<AlertTitle>
|
||||
{data?.status || 500}{" "}
|
||||
{data?.statusText || "Internal Server Error"}
|
||||
</AlertTitle>
|
||||
{data?.error || "Internal Server Error"}
|
||||
</Alert>
|
||||
</Box>
|
||||
{data?.requestId ? (
|
||||
<SectionFooter>
|
||||
<Typography variant="caption">
|
||||
If you should have access, contact your administrator with your
|
||||
request id {data?.requestId}.
|
||||
</Typography>
|
||||
</SectionFooter>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
export default ErrorPage;
|
16
ui/src/components/ExperimentalIcon.tsx
Normal file
16
ui/src/components/ExperimentalIcon.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import createSvgIcon from "@mui/material/utils/createSvgIcon";
|
||||
import React from "react";
|
||||
|
||||
const ExperimentalIcon = createSvgIcon(
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M20.5 19.34 16.14 12a1 1 0 0 1-.14-.51V2.5a.5.5 0 0 1 .5-.5H18a1 1 0 0 0 0-2H6a1 1 0 0 0 0 2h1.5a.5.5 0 0 1 .5.5v9a1 1 0 0 1-.14.51l-4.32 7.27A3 3 0 0 0 6 24h12a3 3 0 0 0 2.49-4.66ZM8.67 16a.5.5 0 0 1-.43-.25.5.5 0 0 1 0-.5l1.62-2.74A1 1 0 0 0 10 12V2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5v2.75a.5.5 0 0 1-.5.5h-1.32a.75.75 0 0 0 0 1.5h1.32a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1.32a.75.75 0 0 0 0 1.5h1.32a.5.5 0 0 1 .5.5V12a1 1 0 0 0 .14.51l1.61 2.74a.47.47 0 0 1 0 .5.52.52 0 0 1-.44.25Zm.82 5.82a1.5 1.5 0 1 1 1.5-1.5 1.5 1.5 0 0 1-1.5 1.5Zm4.22-3a1 1 0 0 1 0-2 1 1 0 0 1 0 2Zm2.49 3.09a1 1 0 1 1 1-1 1 1 0 0 1-1 .98Z"
|
||||
style={{
|
||||
fill: "#5e6a82"
|
||||
}}
|
||||
transform="scale(.83333)"
|
||||
/>
|
||||
</svg>,
|
||||
"Experimental"
|
||||
);
|
||||
export default ExperimentalIcon;
|
39
ui/src/components/Footer.tsx
Normal file
39
ui/src/components/Footer.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import Box from "@mui/material/Box";
|
||||
import Container from "@mui/material/Container";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import React, { FC } from "react";
|
||||
|
||||
const Footer: FC = () => {
|
||||
return (
|
||||
<Container component="footer">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
fontSize: "0.85rem",
|
||||
padding: "16px"
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<a href="https://pomerium.com/">Home</a>
|
||||
</Box>
|
||||
<Box>
|
||||
<a href="https://pomerium.com/docs">Docs</a>
|
||||
</Box>
|
||||
<Box>
|
||||
<a href="https://pomerium.com/docs/community/">Support</a>
|
||||
</Box>
|
||||
<Box>
|
||||
<a href="https://github.com/pomerium">GitHub</a>
|
||||
</Box>
|
||||
<Box>
|
||||
<a href="https://twitter.com/pomerium_io">@pomerium_io</a>
|
||||
</Box>
|
||||
<Box flexGrow={1} sx={{ textAlign: "right" }}>
|
||||
© Pomerium, Inc.
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
export default Footer;
|
41
ui/src/components/GroupDetails.tsx
Normal file
41
ui/src/components/GroupDetails.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { Group } from "../types";
|
||||
import IDField from "./IDField";
|
||||
import Section from "./Section";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type GroupDetailsProps = {
|
||||
groups: Group[];
|
||||
};
|
||||
export const GroupDetails: FC<GroupDetailsProps> = ({ groups }) => {
|
||||
return (
|
||||
<Section title="Groups">
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{groups?.map((group) => (
|
||||
<TableRow key={group?.id}>
|
||||
<TableCell>
|
||||
<IDField value={group?.id} />
|
||||
</TableCell>
|
||||
<TableCell>{group?.name}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
export default GroupDetails;
|
35
ui/src/components/Header.tsx
Normal file
35
ui/src/components/Header.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import CsrfInput from "./CsrfInput";
|
||||
import Logo from "./Logo";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import React, { FC } from "react";
|
||||
|
||||
type HeaderProps = {
|
||||
csrfToken: string;
|
||||
signOutUrl: string;
|
||||
};
|
||||
const Header: FC<HeaderProps> = ({ csrfToken, signOutUrl }) => {
|
||||
return (
|
||||
<AppBar position="sticky">
|
||||
<Toolbar>
|
||||
<a href="/.pomerium">
|
||||
<Logo />
|
||||
</a>
|
||||
<Box flexGrow={1} />
|
||||
{signOutUrl ? (
|
||||
<form action={signOutUrl}>
|
||||
<CsrfInput csrfToken={csrfToken} />
|
||||
<Button variant="text" color="inherit" type="submit">
|
||||
Logout
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
export default Header;
|
24
ui/src/components/HeroSection.tsx
Normal file
24
ui/src/components/HeroSection.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type HeroSectionProps = {
|
||||
icon?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
text?: React.ReactNode;
|
||||
};
|
||||
export const HeroSection: FC<HeroSectionProps> = ({ icon, title, text }) => {
|
||||
return (
|
||||
<Paper sx={{ padding: "16px" }}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
{icon}
|
||||
<Stack>
|
||||
<Typography variant="h1">{title}</Typography>
|
||||
{text ? <Typography>{text}</Typography> : <></>}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
export default HeroSection;
|
19
ui/src/components/IDField.tsx
Normal file
19
ui/src/components/IDField.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import Box from "@mui/material/Box";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type IDFieldProps = {
|
||||
value: string;
|
||||
};
|
||||
export const IDField: FC<IDFieldProps> = ({ value }) => {
|
||||
return (
|
||||
<Box component="span" sx={{ fontFamily: '"DM Mono"', fontSize: "12px" }}>
|
||||
{value?.split("")?.map((str, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{str}
|
||||
<wbr />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
export default IDField;
|
59
ui/src/components/JwtIcon.tsx
Normal file
59
ui/src/components/JwtIcon.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import createSvgIcon from "@mui/material/utils/createSvgIcon";
|
||||
import React from "react";
|
||||
|
||||
const JwtIcon = createSvgIcon(
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 15 15"
|
||||
>
|
||||
<path
|
||||
style={{
|
||||
stroke: "none",
|
||||
fillRule: "evenodd",
|
||||
fill: "#fff",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
d="M8.586 4.04 8.57.042H6.34l.015 3.996L7.47 5.57ZM6.355 10.887v4.008h2.23v-4.008L7.47 9.355Zm0 0"
|
||||
/>
|
||||
<path
|
||||
style={{
|
||||
stroke: "none",
|
||||
fillRule: "evenodd",
|
||||
fill: "#00f2e6",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
d="m8.586 10.887 2.344 3.238 1.797-1.309-2.344-3.238L8.586 9ZM6.355 4.04 3.996.8 2.2 2.11l2.344 3.238 1.812.578Zm0 0"
|
||||
/>
|
||||
<path
|
||||
style={{
|
||||
stroke: "none",
|
||||
fillRule: "evenodd",
|
||||
fill: "#00b9f1",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
d="m4.543 5.348-3.8-1.235-.684 2.11 3.804 1.246 1.797-.594ZM9.266 8.05l1.117 1.528 3.8 1.235.684-2.11-3.805-1.234Zm0 0"
|
||||
/>
|
||||
<path
|
||||
style={{
|
||||
stroke: "none",
|
||||
fillRule: "evenodd",
|
||||
fill: "#d63aff",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
d="m11.063 7.469 3.804-1.246-.683-2.11-3.801 1.235-1.117 1.527ZM3.863 7.469.06 8.703l.683 2.11 3.801-1.235L5.66 8.051Zm0 0"
|
||||
/>
|
||||
<path
|
||||
style={{
|
||||
stroke: "none",
|
||||
fillRule: "evenodd",
|
||||
fill: "#fb015b",
|
||||
fillOpacity: 1
|
||||
}}
|
||||
d="m4.543 9.578-2.344 3.238 1.797 1.309 2.36-3.238V9ZM10.383 5.348l2.344-3.239L10.93.801 8.586 4.039v1.887Zm0 0"
|
||||
/>
|
||||
</svg>,
|
||||
"JWT"
|
||||
);
|
||||
export default JwtIcon;
|
9
ui/src/components/Logo.tsx
Normal file
9
ui/src/components/Logo.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import LogoURL from "../static/logo_white.svg";
|
||||
import React from "react";
|
||||
import type { FC } from "react";
|
||||
|
||||
const Logo: FC = () => {
|
||||
return <img alt="Logo" src={LogoURL} height="30px" />;
|
||||
};
|
||||
|
||||
export default Logo;
|
10
ui/src/components/PersonIcon.tsx
Normal file
10
ui/src/components/PersonIcon.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon";
|
||||
import React, { FC } from "react";
|
||||
import { User } from "react-feather";
|
||||
|
||||
export const PersonIcon: FC<SvgIconProps> = (props) => (
|
||||
<SvgIcon {...props}>
|
||||
<User />
|
||||
</SvgIcon>
|
||||
);
|
||||
export default PersonIcon;
|
41
ui/src/components/Section.tsx
Normal file
41
ui/src/components/Section.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import SectionFooter from "./SectionFooter";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type SectionProps = React.PropsWithChildren<{
|
||||
title: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}>;
|
||||
export const Section: FC<SectionProps> = ({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
footer
|
||||
}) => {
|
||||
return (
|
||||
<Paper sx={{ overflow: "hidden" }}>
|
||||
<Stack>
|
||||
<Toolbar>
|
||||
<Typography variant="h4" flexGrow={1}>
|
||||
{title}
|
||||
</Typography>
|
||||
{icon ? <Box>{icon}</Box> : <></>}
|
||||
</Toolbar>
|
||||
<Box sx={{ padding: 3, paddingTop: 0 }}>{children}</Box>
|
||||
{footer ? (
|
||||
<SectionFooter>
|
||||
<Typography variant="caption">{footer}</Typography>
|
||||
</SectionFooter>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
export default Section;
|
9
ui/src/components/SectionFooter.tsx
Normal file
9
ui/src/components/SectionFooter.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import Box from "@mui/material/Box";
|
||||
import styled from "@mui/material/styles/styled";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const SectionFooter = styled(Box)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.grey[100],
|
||||
padding: theme.spacing(3)
|
||||
}));
|
||||
export default SectionFooter;
|
47
ui/src/components/SessionDetails.tsx
Normal file
47
ui/src/components/SessionDetails.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Session } from "../types";
|
||||
import ClaimsTable from "./ClaimsTable";
|
||||
import IDField from "./IDField";
|
||||
import Section from "./Section";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type SessionDetailsProps = {
|
||||
session: Session;
|
||||
};
|
||||
export const SessionDetails: FC<SessionDetailsProps> = ({ session }) => {
|
||||
return (
|
||||
<Section title="Session Details">
|
||||
<Stack spacing={3}>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>
|
||||
<IDField value={session?.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>User ID</TableCell>
|
||||
<TableCell>
|
||||
<IDField value={session?.userId} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Expires At</TableCell>
|
||||
<TableCell>{session?.expiresAt || ""}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<ClaimsTable claims={session?.claims} />
|
||||
</Stack>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
export default SessionDetails;
|
81
ui/src/components/SessionDeviceCredentials.tsx
Normal file
81
ui/src/components/SessionDeviceCredentials.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import DeviceCredentialsTable from "../components/DeviceCredentialsTable";
|
||||
import SectionFooter from "../components/SectionFooter";
|
||||
import { Session, User } from "../types";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type SessionDeviceCredentialsProps = {
|
||||
csrfToken: string;
|
||||
user: User;
|
||||
session: Session;
|
||||
webAuthnUrl: string;
|
||||
};
|
||||
export const SessionDeviceCredentials: FC<SessionDeviceCredentialsProps> = ({
|
||||
csrfToken,
|
||||
user,
|
||||
session,
|
||||
webAuthnUrl
|
||||
}) => {
|
||||
const currentSessionDeviceCredentialIds = [];
|
||||
const otherDeviceCredentialIds = [];
|
||||
user?.deviceCredentialIds?.forEach((id) => {
|
||||
if (session?.deviceCredentials?.find((cred) => cred?.id === id)) {
|
||||
currentSessionDeviceCredentialIds.push(id);
|
||||
} else {
|
||||
otherDeviceCredentialIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper sx={{ overflow: "hidden" }}>
|
||||
<Stack>
|
||||
<Toolbar>
|
||||
<Typography variant="h4" flexGrow={1}>
|
||||
Current Session Device Credentials
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Box sx={{ padding: 3, paddingTop: 0 }}>
|
||||
<DeviceCredentialsTable
|
||||
csrfToken={csrfToken}
|
||||
ids={currentSessionDeviceCredentialIds}
|
||||
webAuthnUrl={webAuthnUrl}
|
||||
/>
|
||||
</Box>
|
||||
{otherDeviceCredentialIds?.length > 0 ? (
|
||||
<>
|
||||
<Toolbar>
|
||||
<Typography variant="h4" flexGrow={1}>
|
||||
Other Device Credentials
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Box sx={{ padding: 3, paddingTop: 0 }}>
|
||||
<DeviceCredentialsTable
|
||||
csrfToken={csrfToken}
|
||||
ids={otherDeviceCredentialIds}
|
||||
webAuthnUrl={webAuthnUrl}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<SectionFooter>
|
||||
<Typography variant="caption">
|
||||
Register device with <a href={webAuthnUrl}>WebAuthn</a>.
|
||||
</Typography>
|
||||
</SectionFooter>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
export default SessionDeviceCredentials;
|
17
ui/src/components/UserClaims.tsx
Normal file
17
ui/src/components/UserClaims.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { User } from "../types";
|
||||
import ClaimsTable from "./ClaimsTable";
|
||||
import JwtIcon from "./JwtIcon";
|
||||
import Section from "./Section";
|
||||
import React, { FC } from "react";
|
||||
|
||||
export type UserClaimsProps = {
|
||||
user: User;
|
||||
};
|
||||
export const UserClaims: FC<UserClaimsProps> = ({ user }) => {
|
||||
return (
|
||||
<Section title="User Claims" icon={<JwtIcon />}>
|
||||
<ClaimsTable claims={user?.claims} />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
export default UserClaims;
|
55
ui/src/components/UserInfoPage.tsx
Normal file
55
ui/src/components/UserInfoPage.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import GroupDetails from "./GroupDetails";
|
||||
import HeroSection from "./HeroSection";
|
||||
import PersonIcon from "./PersonIcon";
|
||||
import SessionDetails from "./SessionDetails";
|
||||
import SessionDeviceCredentials from "./SessionDeviceCredentials";
|
||||
import UserClaims from "./UserClaims";
|
||||
import MuiAvatar from "@mui/material/Avatar";
|
||||
import Container from "@mui/material/Container";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import styled from "@mui/material/styles/styled";
|
||||
import React, { FC } from "react";
|
||||
import { UserInfoPageData } from "src/types";
|
||||
|
||||
const Avatar = styled(MuiAvatar)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
height: 48,
|
||||
width: 48
|
||||
}));
|
||||
|
||||
type UserInfoPageProps = {
|
||||
data: UserInfoPageData;
|
||||
};
|
||||
const UserInfoPage: FC<UserInfoPageProps> = ({ data }) => {
|
||||
const name = data?.user?.claims?.given_name?.[0] || data?.user?.name;
|
||||
return (
|
||||
<Container>
|
||||
<Stack spacing={3}>
|
||||
<HeroSection
|
||||
icon={
|
||||
<Avatar>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
}
|
||||
title={<>Hi {name}!</>}
|
||||
text={
|
||||
<>
|
||||
Welcome to the user info endpoint. Here you can view your current
|
||||
session details, and authorization context.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SessionDetails session={data?.session} />
|
||||
<UserClaims user={data?.user} />
|
||||
<GroupDetails groups={data?.directoryGroups} />
|
||||
<SessionDeviceCredentials
|
||||
csrfToken={data?.csrfToken}
|
||||
session={data?.session}
|
||||
user={data?.user}
|
||||
webAuthnUrl={data?.webAuthnUrl}
|
||||
/>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
export default UserInfoPage;
|
210
ui/src/components/WebAuthnRegistrationPage.tsx
Normal file
210
ui/src/components/WebAuthnRegistrationPage.tsx
Normal file
|
@ -0,0 +1,210 @@
|
|||
import { decode, encodeUrl } from "../util/base64";
|
||||
import AlertDialog from "./AlertDialog";
|
||||
import ExperimentalIcon from "./ExperimentalIcon";
|
||||
import HeroSection from "./HeroSection";
|
||||
import Button from "@mui/material/Button";
|
||||
import Container from "@mui/material/Container";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
import {
|
||||
WebAuthnCreationOptions,
|
||||
WebAuthnRegistrationPageData,
|
||||
WebAuthnRequestOptions
|
||||
} from "src/types";
|
||||
|
||||
type CredentialForAuthenticate = {
|
||||
id: string;
|
||||
type: string;
|
||||
rawId: ArrayBuffer;
|
||||
response: {
|
||||
authenticatorData: ArrayBuffer;
|
||||
clientDataJSON: ArrayBuffer;
|
||||
signature: ArrayBuffer;
|
||||
userHandle: ArrayBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
async function authenticateCredential(
|
||||
requestOptions: WebAuthnRequestOptions
|
||||
): Promise<CredentialForAuthenticate> {
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: {
|
||||
allowCredentials: requestOptions?.allowCredentials?.map((c) => ({
|
||||
type: c.type,
|
||||
id: decode(c.id)
|
||||
})),
|
||||
challenge: decode(requestOptions?.challenge),
|
||||
timeout: requestOptions?.timeout,
|
||||
userVerification: requestOptions?.userVerification
|
||||
}
|
||||
});
|
||||
return credential as CredentialForAuthenticate;
|
||||
}
|
||||
|
||||
type CredentialForCreate = {
|
||||
id: string;
|
||||
type: string;
|
||||
rawId: ArrayBuffer;
|
||||
response: {
|
||||
attestationObject: ArrayBuffer;
|
||||
clientDataJSON: ArrayBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
async function createCredential(
|
||||
creationOptions: WebAuthnCreationOptions
|
||||
): Promise<CredentialForCreate> {
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
attestation: creationOptions?.attestation || undefined,
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment:
|
||||
creationOptions?.authenticatorSelection?.authenticatorAttachment ||
|
||||
undefined,
|
||||
requireResidentKey:
|
||||
creationOptions?.authenticatorSelection?.requireResidentKey ||
|
||||
undefined,
|
||||
residentKey: creationOptions?.authenticatorSelection?.residentKey,
|
||||
userVerification:
|
||||
creationOptions?.authenticatorSelection?.userVerification || undefined
|
||||
},
|
||||
challenge: decode(creationOptions?.challenge),
|
||||
pubKeyCredParams: creationOptions?.pubKeyCredParams?.map((p) => ({
|
||||
type: p.type,
|
||||
alg: p.alg
|
||||
})),
|
||||
rp: {
|
||||
name: creationOptions?.rp?.name
|
||||
},
|
||||
timeout: creationOptions?.timeout,
|
||||
user: {
|
||||
id: decode(creationOptions?.user?.id),
|
||||
name: creationOptions?.user?.name,
|
||||
displayName: creationOptions?.user?.displayName
|
||||
}
|
||||
}
|
||||
});
|
||||
return credential as CredentialForCreate;
|
||||
}
|
||||
|
||||
type WebAuthnRegistrationPageProps = {
|
||||
data: WebAuthnRegistrationPageData;
|
||||
};
|
||||
const WebAuthnRegistrationPage: FC<WebAuthnRegistrationPageProps> = ({
|
||||
data
|
||||
}) => {
|
||||
const authenticateFormRef = useRef<HTMLFormElement>();
|
||||
const authenticateResponseRef = useRef<HTMLInputElement>();
|
||||
const registerFormRef = useRef<HTMLFormElement>();
|
||||
const registerResponseRef = useRef<HTMLInputElement>();
|
||||
|
||||
const [error, setError] = useState<string>(null);
|
||||
|
||||
const enableAuthenticate = data?.requestOptions?.allowCredentials?.length > 0;
|
||||
|
||||
function handleClickAuthenticate(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const credential = await authenticateCredential(data?.requestOptions);
|
||||
authenticateResponseRef.current.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeUrl(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeUrl(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeUrl(credential.response.clientDataJSON),
|
||||
signature: encodeUrl(credential.response.signature),
|
||||
userHandle: encodeUrl(credential.response.userHandle)
|
||||
}
|
||||
});
|
||||
authenticateFormRef.current.submit();
|
||||
} catch (e) {
|
||||
setError(`${e}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
function handleClickDialogOK(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
setError(null);
|
||||
}
|
||||
function handleClickRegister(evt: React.MouseEvent): void {
|
||||
evt.preventDefault();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const credential = await createCredential(data?.creationOptions);
|
||||
registerResponseRef.current.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeUrl(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encodeUrl(credential.response.attestationObject),
|
||||
clientDataJSON: encodeUrl(credential.response.clientDataJSON)
|
||||
}
|
||||
});
|
||||
registerFormRef.current.submit();
|
||||
} catch (e) {
|
||||
setError(`${e}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Stack spacing={3}>
|
||||
<HeroSection
|
||||
title={
|
||||
<>
|
||||
WebAuthn Registration <ExperimentalIcon />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Paper sx={{ padding: "16px" }}>
|
||||
<Stack direction="row" justifyContent="center" spacing={3}>
|
||||
<Button onClick={handleClickRegister} variant="contained">
|
||||
Register New Device
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClickAuthenticate}
|
||||
variant="contained"
|
||||
disabled={!enableAuthenticate}
|
||||
>
|
||||
Authenticate Existing Device
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<form ref={authenticateFormRef} method="post" action={data?.selfUrl}>
|
||||
<input type="hidden" name="_pomerium_csrf" value={data?.csrfToken} />
|
||||
<input type="hidden" name="action" value="authenticate" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="authenticate_response"
|
||||
ref={authenticateResponseRef}
|
||||
/>
|
||||
</form>
|
||||
<form ref={registerFormRef} method="POST" action={data?.selfUrl}>
|
||||
<input type="hidden" name="_pomerium_csrf" value={data?.csrfToken} />
|
||||
<input type="hidden" name="action" value="register" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="register_response"
|
||||
ref={registerResponseRef}
|
||||
/>
|
||||
</form>
|
||||
</Stack>
|
||||
<AlertDialog
|
||||
title="Error"
|
||||
severity="error"
|
||||
open={!!error}
|
||||
actions={<Button onClick={handleClickDialogOK}>OK</Button>}
|
||||
>
|
||||
{error}
|
||||
</AlertDialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
export default WebAuthnRegistrationPage;
|
Loading…
Add table
Add a link
Reference in a new issue