feat: create idp sync page skeleton (#14543)

* feat: initial commit for idp skeleton page

* feat: add optional tooltip icon to settings header

* feat: add help tooltip

* feat: add mock data and update pageview for mock data

* feat: initial stories

* feat: error circle

* feat: cleanup

* feat: update StatusIndicator for outlined variant

* feat: use StatusIndicator instead of Circle

* chore: cleanup

* fix: remove ternaries in css

* fix: updates for PR review comments

* chore: add story for compact empty state

* feat: extract IdpField and improve field spacing
This commit is contained in:
Jaayden Halko
2024-09-06 14:30:41 -05:00
committed by GitHub
parent 84d312cfea
commit 6b9e1d4771
12 changed files with 550 additions and 68 deletions

View File

@ -13,11 +13,17 @@ const meta: Meta<typeof EmptyState> = {
export default meta;
type Story = StoryObj<typeof EmptyState>;
const Example: Story = {
export const Example: Story = {
args: {
description: "It is easy, just click the button below",
cta: <Button>Create workspace</Button>,
},
};
export { Example as EmptyState };
export const Compact: Story = {
args: {
description: "It is easy, just click the button below",
cta: <Button>Create workspace</Button>,
isCompact: true,
},
};

View File

@ -7,6 +7,7 @@ export interface EmptyStateProps extends HTMLAttributes<HTMLDivElement> {
description?: string | ReactNode;
cta?: ReactNode;
image?: ReactNode;
isCompact?: boolean;
}
/**
@ -19,21 +20,28 @@ export const EmptyState: FC<EmptyStateProps> = ({
description,
cta,
image,
isCompact,
...attrs
}) => {
return (
<div
css={{
overflow: "hidden",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
minHeight: 360,
padding: "80px 40px",
position: "relative",
}}
css={[
{
overflow: "hidden",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
minHeight: 360,
padding: "80px 40px",
position: "relative",
},
isCompact && {
minHeight: 180,
padding: "10px 40px",
},
]}
{...attrs}
>
<h5 css={{ fontSize: 24, fontWeight: 500, margin: 0 }}>{message}</h5>

View File

@ -9,6 +9,7 @@ interface HeaderProps {
description?: ReactNode;
secondary?: boolean;
docsHref?: string;
tooltip?: ReactNode;
}
export const SettingsHeader: FC<HeaderProps> = ({
@ -16,32 +17,36 @@ export const SettingsHeader: FC<HeaderProps> = ({
description,
docsHref,
secondary,
tooltip,
}) => {
const theme = useTheme();
return (
<Stack alignItems="baseline" direction="row" justifyContent="space-between">
<div css={{ maxWidth: 420, marginBottom: 24 }}>
<h1
css={[
{
fontSize: 32,
fontWeight: 700,
display: "flex",
alignItems: "center",
lineHeight: "initial",
margin: 0,
marginBottom: 4,
gap: 8,
},
secondary && {
fontSize: 24,
fontWeight: 500,
},
]}
>
{title}
</h1>
<Stack direction="row" spacing={1} alignItems="center">
<h1
css={[
{
fontSize: 32,
fontWeight: 700,
display: "flex",
alignItems: "center",
lineHeight: "initial",
margin: 0,
marginBottom: 4,
gap: 8,
},
secondary && {
fontSize: 24,
fontWeight: 500,
},
]}
>
{title}
</h1>
{tooltip}
</Stack>
{description && (
<span
css={{

View File

@ -4,19 +4,30 @@ import type { ThemeRole } from "theme/roles";
interface StatusIndicatorProps {
color: ThemeRole;
variant?: "solid" | "outlined";
}
export const StatusIndicator: FC<StatusIndicatorProps> = ({ color }) => {
export const StatusIndicator: FC<StatusIndicatorProps> = ({
color,
variant = "solid",
}) => {
const theme = useTheme();
return (
<div
css={{
height: 8,
width: 8,
borderRadius: 4,
backgroundColor: theme.roles[color].fill.solid,
}}
css={[
{
height: 8,
width: 8,
borderRadius: 4,
},
variant === "solid" && {
backgroundColor: theme.roles[color].fill.solid,
},
variant === "outlined" && {
border: `1px solid ${theme.roles[color].outline}`,
},
]}
/>
);
};

View File

@ -0,0 +1,31 @@
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipLink,
HelpTooltipLinksGroup,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import type { FC } from "react";
import { docs } from "utils/docs";
export const IdpSyncHelpTooltip: FC = () => {
return (
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipTitle>What is IdP Sync?</HelpTooltipTitle>
<HelpTooltipText>
View the current mappings between your external OIDC provider and
Coder. Use the Coder CLI to configure these mappings.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href={docs("/admin/auth#group-sync-enterprise")}>
Configure IdP Sync
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</HelpTooltipContent>
</HelpTooltip>
);
};

View File

@ -0,0 +1,97 @@
import AddIcon from "@mui/icons-material/AddOutlined";
import LaunchOutlined from "@mui/icons-material/LaunchOutlined";
import Button from "@mui/material/Button";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack";
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { Link as RouterLink } from "react-router-dom";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import { IdpSyncHelpTooltip } from "./IdpSyncHelpTooltip";
import IdpSyncPageView from "./IdpSyncPageView";
const mockOIDCConfig = {
allow_signups: true,
client_id: "test",
client_secret: "test",
client_key_file: "test",
client_cert_file: "test",
email_domain: [],
issuer_url: "test",
scopes: [],
ignore_email_verified: true,
username_field: "",
name_field: "",
email_field: "",
auth_url_params: {},
ignore_user_info: true,
organization_field: "",
organization_mapping: {},
organization_assign_default: true,
group_auto_create: false,
group_regex_filter: "^Coder-.*$",
group_allow_list: [],
groups_field: "groups",
group_mapping: { group1: "developers", group2: "admin", group3: "auditors" },
user_role_field: "roles",
user_role_mapping: { role1: ["role1", "role2"] },
user_roles_default: [],
sign_in_text: "",
icon_url: "",
signups_disabled_text: "string",
skip_issuer_checks: true,
};
export const IdpSyncPage: FC = () => {
// feature visibility and permissions to be implemented when integrating with backend
// const feats = useFeatureVisibility();
// const { organization: organizationName } = useParams() as {
// organization: string;
// };
// const { organizations } = useOrganizationSettings();
// const organization = organizations?.find((o) => o.name === organizationName);
// const permissionsQuery = useQuery(organizationPermissions(organization?.id));
// const permissions = permissionsQuery.data;
// if (!permissions) {
// return <Loader />;
// }
return (
<>
<Helmet>
<title>{pageTitle("IdP Sync")}</title>
</Helmet>
<Stack
alignItems="baseline"
direction="row"
justifyContent="space-between"
>
<SettingsHeader
title="IdP Sync"
description="Group and role sync mappings (configured outside Coder)."
tooltip={<IdpSyncHelpTooltip />}
/>
<Stack direction="row" spacing={2}>
<Button
startIcon={<LaunchOutlined />}
component="a"
href={docs("/admin/auth#group-sync-enterprise")}
target="_blank"
>
Setup IdP Sync
</Button>
<Button component={RouterLink} startIcon={<AddIcon />} to="export">
Export Policy
</Button>
</Stack>
</Stack>
<IdpSyncPageView oidcConfig={mockOIDCConfig} />
</>
);
};
export default IdpSyncPage;

View File

@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockOIDCConfig } from "testHelpers/entities";
import { IdpSyncPageView } from "./IdpSyncPageView";
const meta: Meta<typeof IdpSyncPageView> = {
title: "pages/OrganizationIdpSyncPage",
component: IdpSyncPageView,
};
export default meta;
type Story = StoryObj<typeof IdpSyncPageView>;
export const Empty: Story = {
args: { oidcConfig: undefined },
};
export const Default: Story = {
args: { oidcConfig: MockOIDCConfig },
};

View File

@ -0,0 +1,284 @@
import type { Interpolation, Theme } from "@emotion/react";
import { useTheme } from "@emotion/react";
import LaunchOutlined from "@mui/icons-material/LaunchOutlined";
import Button from "@mui/material/Button";
import Skeleton from "@mui/material/Skeleton";
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 type { OIDCConfig } from "api/typesGenerated";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Paywall } from "components/Paywall/Paywall";
import { Stack } from "components/Stack/Stack";
import { StatusIndicator } from "components/StatusIndicator/StatusIndicator";
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import type { FC } from "react";
import { docs } from "utils/docs";
export type IdpSyncPageViewProps = {
oidcConfig: OIDCConfig | undefined;
};
export const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({ oidcConfig }) => {
const theme = useTheme();
const {
groups_field,
user_role_field,
group_regex_filter,
group_auto_create,
} = oidcConfig || {};
return (
<>
<ChooseOne>
<Cond condition={false}>
<Paywall
message="IdP Sync"
description="Configure group and role mappings to manage permissions outside of Coder."
documentationLink={docs("/admin/groups")}
/>
</Cond>
<Cond>
<Stack spacing={2} css={styles.fields}>
{/* Semantically fieldset is used for forms. In the future this screen will allow
updates to these fields in a form */}
<fieldset css={styles.box}>
<legend css={styles.legend}>Groups</legend>
<Stack direction={"row"} alignItems={"center"} spacing={8}>
<IdpField
name={"Sync Field"}
fieldText={groups_field}
showStatusIndicator
/>
<IdpField
name={"Regex Filter"}
fieldText={group_regex_filter}
/>
<IdpField
name={"Auto Create"}
fieldText={group_auto_create?.toString()}
/>
</Stack>
</fieldset>
<fieldset css={styles.box}>
<legend css={styles.legend}>Roles</legend>
<Stack direction={"row"} alignItems={"center"} spacing={3}>
<IdpField
name={"Sync Field"}
fieldText={user_role_field}
showStatusIndicator
/>
</Stack>
</fieldset>
</Stack>
<Stack spacing={6}>
<IdpMappingTable
type="Role"
isEmpty={Boolean(
!oidcConfig?.user_role_mapping ||
Object.entries(oidcConfig?.user_role_mapping).length === 0,
)}
>
<>
{oidcConfig?.user_role_mapping &&
Object.entries(oidcConfig.user_role_mapping)
.sort()
.map(([idpRole, roles]) => (
<RoleRow
key={idpRole}
idpRole={idpRole}
coderRoles={roles}
/>
))}
</>
</IdpMappingTable>
<IdpMappingTable
type="Group"
isEmpty={Boolean(
!oidcConfig?.group_mapping ||
Object.entries(oidcConfig?.group_mapping).length === 0,
)}
>
<>
{oidcConfig?.user_role_mapping &&
Object.entries(oidcConfig.group_mapping)
.sort()
.map(([idpGroup, group]) => (
<GroupRow
key={idpGroup}
idpGroup={idpGroup}
coderGroup={group}
/>
))}
</>
</IdpMappingTable>
</Stack>
</Cond>
</ChooseOne>
</>
);
};
interface IdpFieldProps {
name: string;
fieldText: string | undefined;
showStatusIndicator?: boolean;
}
const IdpField: FC<IdpFieldProps> = ({
name,
fieldText,
showStatusIndicator = false,
}) => {
return (
<span css={{ display: "flex", alignItems: "center", gap: "16px" }}>
<h4>{name}</h4>
<p css={styles.secondary}>
{fieldText ||
(showStatusIndicator && (
<div
css={{
display: "flex",
alignItems: "center",
gap: "8px",
height: 0,
}}
>
<StatusIndicator color="error" />
<p>disabled</p>
</div>
))}
</p>
</span>
);
};
interface IdpMappingTableProps {
type: "Role" | "Group";
isEmpty: boolean;
children: React.ReactNode;
}
const IdpMappingTable: FC<IdpMappingTableProps> = ({
type,
isEmpty,
children,
}) => {
const isLoading = false;
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="45%">Idp {type}</TableCell>
<TableCell width="55%">Coder {type}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={`No ${type} Mappings`}
isCompact
cta={
<Button
startIcon={<LaunchOutlined />}
component="a"
href={docs("/admin/auth#group-sync-enterprise")}
target="_blank"
>
How to setup IdP {type} sync
</Button>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
);
};
interface GroupRowProps {
idpGroup: string;
coderGroup: string;
}
const GroupRow: FC<GroupRowProps> = ({ idpGroup, coderGroup }) => {
return (
<TableRow data-testid={`group-${idpGroup}`}>
<TableCell>{idpGroup}</TableCell>
<TableCell css={styles.secondary}>{coderGroup}</TableCell>
</TableRow>
);
};
interface RoleRowProps {
idpRole: string;
coderRoles: ReadonlyArray<string>;
}
const RoleRow: FC<RoleRowProps> = ({ idpRole, coderRoles }) => {
return (
<TableRow data-testid={`role-${idpRole}`}>
<TableCell>{idpRole}</TableCell>
<TableCell css={styles.secondary}>coderRoles Placeholder</TableCell>
</TableRow>
);
};
const TableLoader = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
);
};
const styles = {
secondary: (theme) => ({
color: theme.palette.text.secondary,
}),
fields: () => ({
marginBottom: "60px",
}),
legend: () => ({
padding: "0px 6px",
fontWeight: 600,
}),
box: (theme) => ({
border: "1px solid",
borderColor: theme.palette.divider,
padding: "0px 20px",
borderRadius: 8,
}),
} satisfies Record<string, Interpolation<Theme>>;
export default IdpSyncPageView;

View File

@ -282,6 +282,13 @@ const OrganizationSettingsNavigation: FC<
Provisioners
</SidebarNavSubItem>
)}
{organization.permissions.editMembers && (
<SidebarNavSubItem
href={urlForSubpage(organization.name, "idp-sync")}
>
IdP Sync
</SidebarNavSubItem>
)}
</Stack>
)}
</>

View File

@ -1,32 +1,12 @@
import { useTheme } from "@emotion/react";
import { Stack } from "components/Stack/Stack";
import { StatusIndicator } from "components/StatusIndicator/StatusIndicator";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useTime } from "hooks/useTime";
import type { FC } from "react";
dayjs.extend(relativeTime);
type CircleProps = {
color: string;
variant?: "solid" | "outlined";
};
const Circle: FC<CircleProps> = ({ color, variant = "solid" }) => {
return (
<div
aria-hidden
css={{
width: 8,
height: 8,
backgroundColor: variant === "solid" ? color : undefined,
border: variant === "outlined" ? `1px solid ${color}` : undefined,
borderRadius: 9999,
}}
/>
);
};
interface LastUsedProps {
lastUsedAt: string;
}
@ -38,21 +18,19 @@ export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
const t = dayjs(lastUsedAt);
const now = dayjs();
let message = t.fromNow();
let circle = (
<Circle color={theme.palette.text.secondary} variant="outlined" />
);
let circle = <StatusIndicator color="info" variant="outlined" />;
if (t.isAfter(now.subtract(1, "hour"))) {
circle = <Circle color={theme.roles.success.fill.solid} />;
circle = <StatusIndicator color="success" />;
// Since the agent reports on a 10m interval,
// the last_used_at can be inaccurate when recent.
message = "Now";
} else if (t.isAfter(now.subtract(3, "day"))) {
circle = <Circle color={theme.palette.text.secondary} />;
circle = <StatusIndicator color="info" />;
} else if (t.isAfter(now.subtract(1, "month"))) {
circle = <Circle color={theme.roles.warning.fill.solid} />;
circle = <StatusIndicator color="warning" />;
} else if (t.isAfter(now.subtract(100, "year"))) {
circle = <Circle color={theme.roles.error.fill.solid} />;
circle = <StatusIndicator color="error" />;
} else {
message = "Never";
}

View File

@ -247,6 +247,9 @@ const OrganizationCustomRolesPage = lazy(
() =>
import("./pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage"),
);
const OrganizationIdPSyncPage = lazy(
() => import("./pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage"),
);
const CreateEditRolePage = lazy(
() =>
import("./pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage"),
@ -406,6 +409,7 @@ export const router = createBrowserRouter(
path="provisioners"
element={<OrganizationProvisionersPage />}
/>
<Route path="idp-sync" element={<OrganizationIdPSyncPage />} />
</Route>
</Route>

View File

@ -451,6 +451,38 @@ export const MockAssignableSiteRoles = [
assignableRole(MockAuditorRole, true),
];
export const MockOIDCConfig: TypesGen.OIDCConfig = {
allow_signups: true,
client_id: "test",
client_secret: "test",
client_key_file: "test",
client_cert_file: "test",
email_domain: [],
issuer_url: "test",
scopes: [],
ignore_email_verified: true,
username_field: "",
name_field: "",
email_field: "",
auth_url_params: {},
ignore_user_info: true,
organization_field: "",
organization_mapping: {},
organization_assign_default: true,
group_auto_create: false,
group_regex_filter: "^Coder-.*$",
group_allow_list: [],
groups_field: "groups",
group_mapping: { group1: "developers", group2: "admin", group3: "auditors" },
user_role_field: "roles",
user_role_mapping: { role1: ["role1", "role2"] },
user_roles_default: [],
sign_in_text: "",
icon_url: "",
signups_disabled_text: "string",
skip_issuer_checks: true,
};
export const MockMemberPermissions = {
viewAuditLog: false,
};