diff --git a/site/src/components/EmptyState/EmptyState.stories.tsx b/site/src/components/EmptyState/EmptyState.stories.tsx index cda4f69a01db7..1e4c5eb183076 100644 --- a/site/src/components/EmptyState/EmptyState.stories.tsx +++ b/site/src/components/EmptyState/EmptyState.stories.tsx @@ -13,11 +13,17 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const Example: Story = { +export const Example: Story = { args: { description: "It is easy, just click the button below", cta: , }, }; -export { Example as EmptyState }; +export const Compact: Story = { + args: { + description: "It is easy, just click the button below", + cta: , + isCompact: true, + }, +}; diff --git a/site/src/components/EmptyState/EmptyState.tsx b/site/src/components/EmptyState/EmptyState.tsx index c34f634d71bab..1371d7e9fa56e 100644 --- a/site/src/components/EmptyState/EmptyState.tsx +++ b/site/src/components/EmptyState/EmptyState.tsx @@ -7,6 +7,7 @@ export interface EmptyStateProps extends HTMLAttributes { description?: string | ReactNode; cta?: ReactNode; image?: ReactNode; + isCompact?: boolean; } /** @@ -19,21 +20,28 @@ export const EmptyState: FC = ({ description, cta, image, + isCompact, ...attrs }) => { return (
{message}
diff --git a/site/src/components/SettingsHeader/SettingsHeader.tsx b/site/src/components/SettingsHeader/SettingsHeader.tsx index 954c7b6a64341..ea68415cc1e5b 100644 --- a/site/src/components/SettingsHeader/SettingsHeader.tsx +++ b/site/src/components/SettingsHeader/SettingsHeader.tsx @@ -9,6 +9,7 @@ interface HeaderProps { description?: ReactNode; secondary?: boolean; docsHref?: string; + tooltip?: ReactNode; } export const SettingsHeader: FC = ({ @@ -16,32 +17,36 @@ export const SettingsHeader: FC = ({ description, docsHref, secondary, + tooltip, }) => { const theme = useTheme(); return (
-

- {title} -

+ +

+ {title} +

+ {tooltip} +
{description && ( = ({ color }) => { +export const StatusIndicator: FC = ({ + color, + variant = "solid", +}) => { const theme = useTheme(); return (
); }; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncHelpTooltip.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncHelpTooltip.tsx new file mode 100644 index 0000000000000..d2907e4d192f7 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncHelpTooltip.tsx @@ -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 ( + + + + What is IdP Sync? + + View the current mappings between your external OIDC provider and + Coder. Use the Coder CLI to configure these mappings. + + + + Configure IdP Sync + + + + + ); +}; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx new file mode 100644 index 0000000000000..a865bf9c258c7 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPage.tsx @@ -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 ; + // } + + return ( + <> + + {pageTitle("IdP Sync")} + + + + } + /> + + + + + + + + + ); +}; + +export default IdpSyncPage; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx new file mode 100644 index 0000000000000..47952edc61c95 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MockOIDCConfig } from "testHelpers/entities"; +import { IdpSyncPageView } from "./IdpSyncPageView"; + +const meta: Meta = { + title: "pages/OrganizationIdpSyncPage", + component: IdpSyncPageView, +}; + +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { oidcConfig: undefined }, +}; + +export const Default: Story = { + args: { oidcConfig: MockOIDCConfig }, +}; diff --git a/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx new file mode 100644 index 0000000000000..44552f11f7429 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/IdpSyncPage/IdpSyncPageView.tsx @@ -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 = ({ oidcConfig }) => { + const theme = useTheme(); + const { + groups_field, + user_role_field, + group_regex_filter, + group_auto_create, + } = oidcConfig || {}; + return ( + <> + + + + + + + {/* Semantically fieldset is used for forms. In the future this screen will allow + updates to these fields in a form */} +
+ Groups + + + + + +
+
+ Roles + + + +
+
+ + + <> + {oidcConfig?.user_role_mapping && + Object.entries(oidcConfig.user_role_mapping) + .sort() + .map(([idpRole, roles]) => ( + + ))} + + + + <> + {oidcConfig?.user_role_mapping && + Object.entries(oidcConfig.group_mapping) + .sort() + .map(([idpGroup, group]) => ( + + ))} + + + +
+
+ + ); +}; + +interface IdpFieldProps { + name: string; + fieldText: string | undefined; + showStatusIndicator?: boolean; +} + +const IdpField: FC = ({ + name, + fieldText, + showStatusIndicator = false, +}) => { + return ( + +

{name}

+

+ {fieldText || + (showStatusIndicator && ( +

+ +

disabled

+
+ ))} +

+
+ ); +}; + +interface IdpMappingTableProps { + type: "Role" | "Group"; + isEmpty: boolean; + children: React.ReactNode; +} + +const IdpMappingTable: FC = ({ + type, + isEmpty, + children, +}) => { + const isLoading = false; + + return ( + + + + + Idp {type} + Coder {type} + + + + + + + + + + + + } + component="a" + href={docs("/admin/auth#group-sync-enterprise")} + target="_blank" + > + How to setup IdP {type} sync + + } + /> + + + + + {children} + + +
+
+ ); +}; + +interface GroupRowProps { + idpGroup: string; + coderGroup: string; +} + +const GroupRow: FC = ({ idpGroup, coderGroup }) => { + return ( + + {idpGroup} + {coderGroup} + + ); +}; + +interface RoleRowProps { + idpRole: string; + coderRoles: ReadonlyArray; +} + +const RoleRow: FC = ({ idpRole, coderRoles }) => { + return ( + + {idpRole} + coderRoles Placeholder + + ); +}; + +const TableLoader = () => { + return ( + + + + + + + + + + + + + + ); +}; + +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>; + +export default IdpSyncPageView; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index d635279a4d94a..795458794bc53 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -282,6 +282,13 @@ const OrganizationSettingsNavigation: FC< Provisioners )} + {organization.permissions.editMembers && ( + + IdP Sync + + )} )} diff --git a/site/src/pages/WorkspacesPage/LastUsed.tsx b/site/src/pages/WorkspacesPage/LastUsed.tsx index 685035ce4b28c..7de93efb005a6 100644 --- a/site/src/pages/WorkspacesPage/LastUsed.tsx +++ b/site/src/pages/WorkspacesPage/LastUsed.tsx @@ -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 = ({ color, variant = "solid" }) => { - return ( -
- ); -}; - interface LastUsedProps { lastUsedAt: string; } @@ -38,21 +18,19 @@ export const LastUsed: FC = ({ lastUsedAt }) => { const t = dayjs(lastUsedAt); const now = dayjs(); let message = t.fromNow(); - let circle = ( - - ); + let circle = ; if (t.isAfter(now.subtract(1, "hour"))) { - circle = ; + circle = ; // 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 = ; } else if (t.isAfter(now.subtract(1, "month"))) { - circle = ; + circle = ; } else if (t.isAfter(now.subtract(100, "year"))) { - circle = ; + circle = ; } else { message = "Never"; } diff --git a/site/src/router.tsx b/site/src/router.tsx index a85cdb9a31bfb..c5c9e68f0f314 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -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={} /> + } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3948dc148927a..f77bae6c5a1b4 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, };