refactor: User settings page (#5661)

This commit is contained in:
Bruno Quaresma
2023-01-10 17:57:08 -03:00
committed by GitHub
parent d5ab06ed68
commit f5d623ff3f
11 changed files with 324 additions and 57 deletions
+31 -4
View File
@@ -420,10 +420,37 @@ export const AppRouter: FC = () => {
/>
</Route>
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="settings">
<Route
path="account"
element={
<AuthAndFrame>
<SettingsLayout>
<AccountPage />
</SettingsLayout>
</AuthAndFrame>
}
/>
<Route
path="security"
element={
<AuthAndFrame>
<SettingsLayout>
<SecurityPage />
</SettingsLayout>
</AuthAndFrame>
}
/>
<Route
path="ssh-keys"
element={
<AuthAndFrame>
<SettingsLayout>
<SSHKeysPage />
</SettingsLayout>
</AuthAndFrame>
}
/>
</Route>
<Route path="/@:username">
@@ -45,7 +45,7 @@ export const DeploySettingsLayout: FC<PropsWithChildren> = ({ children }) => {
return (
<Margins>
<Stack className={styles.wrapper} direction="row" spacing={5}>
<Stack className={styles.wrapper} direction="row" spacing={6}>
<Sidebar />
<main className={styles.content}>
{deploymentConfig ? (
@@ -1,8 +1,8 @@
import { makeStyles } from "@material-ui/core/styles"
import Brush from "@material-ui/icons/Brush"
import LaunchOutlined from "@material-ui/icons/LaunchOutlined"
import LockRounded from "@material-ui/icons/LockRounded"
import Globe from "@material-ui/icons/Public"
import LockRounded from "@material-ui/icons/LockOutlined"
import Globe from "@material-ui/icons/PublicOutlined"
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
import { GitIcon } from "components/Icons/GitIcon"
import { Stack } from "components/Stack/Stack"
@@ -90,9 +90,9 @@ const useStyles = makeStyles((theme) => ({
sidebarNavItem: {
color: "inherit",
display: "block",
fontSize: 16,
fontSize: 14,
textDecoration: "none",
padding: theme.spacing(1.5, 1.5, 1.5, 3),
padding: theme.spacing(1.5, 1.5, 1.5, 2),
borderRadius: theme.shape.borderRadius / 2,
transition: "background-color 0.15s ease-in-out",
marginBottom: 1,
@@ -115,7 +115,8 @@ const useStyles = makeStyles((theme) => ({
left: 0,
top: 0,
backgroundColor: theme.palette.secondary.dark,
borderRadius: theme.shape.borderRadius,
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
},
@@ -0,0 +1,79 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import { FC } from "react"
import { SectionAction } from "../SectionAction/SectionAction"
type SectionLayout = "fixed" | "fluid"
export interface SectionProps {
title?: React.ReactNode | string
description?: React.ReactNode
toolbar?: React.ReactNode
alert?: React.ReactNode
layout?: SectionLayout
className?: string
children?: React.ReactNode
}
type SectionFC = FC<React.PropsWithChildren<SectionProps>> & {
Action: typeof SectionAction
}
export const Section: SectionFC = ({
title,
description,
toolbar,
alert,
className = "",
children,
layout = "fixed",
}) => {
const styles = useStyles({ layout })
return (
<section className={className}>
<div className={styles.inner}>
{(title || description) && (
<div className={styles.header}>
<div>
{title && <Typography variant="h4">{title}</Typography>}
{description && typeof description === "string" && (
<Typography className={styles.description}>
{description}
</Typography>
)}
{description && typeof description !== "string" && (
<div className={styles.description}>{description}</div>
)}
</div>
{toolbar && <div>{toolbar}</div>}
</div>
)}
{alert && <div className={styles.alert}>{alert}</div>}
{children}
</div>
</section>
)
}
// Sub-components
Section.Action = SectionAction
const useStyles = makeStyles((theme) => ({
inner: ({ layout }: { layout: SectionLayout }) => ({
maxWidth: layout === "fluid" ? "100%" : 500,
}),
alert: {
marginBottom: theme.spacing(1),
},
header: {
marginBottom: theme.spacing(3),
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
},
description: {
color: theme.palette.text.secondary,
fontSize: 16,
marginTop: theme.spacing(0.5),
},
}))
@@ -1,38 +1,42 @@
import Box from "@material-ui/core/Box"
import { FC } from "react"
import { makeStyles } from "@material-ui/core/styles"
import { Sidebar } from "./Sidebar"
import { Stack } from "components/Stack/Stack"
import { FC, PropsWithChildren, Suspense } from "react"
import { Helmet } from "react-helmet-async"
import { Outlet } from "react-router-dom"
import { pageTitle } from "../../util/page"
import { AuthAndFrame } from "../AuthAndFrame/AuthAndFrame"
import { Margins } from "../Margins/Margins"
import { TabPanel } from "../TabPanel/TabPanel"
import { useMe } from "hooks/useMe"
import { Loader } from "components/Loader/Loader"
export const Language = {
accountLabel: "Account",
securityLabel: "Security",
sshKeysLabel: "SSH keys",
settingsLabel: "Settings",
}
export const SettingsLayout: FC<PropsWithChildren> = ({ children }) => {
const styles = useStyles()
const me = useMe()
const menuItems = [
{ label: Language.accountLabel, path: "/settings/account" },
{ label: Language.securityLabel, path: "/settings/security" },
{ label: Language.sshKeysLabel, path: "/settings/ssh-keys" },
]
export const SettingsLayout: FC = () => {
return (
<AuthAndFrame>
<Box display="flex" flexDirection="column">
<Helmet>
<title>{pageTitle("Settings")}</title>
</Helmet>
<Margins>
<TabPanel title={Language.settingsLabel} menuItems={menuItems}>
<Outlet />
</TabPanel>
</Margins>
</Box>
</AuthAndFrame>
<>
<Helmet>
<title>{pageTitle("Settings")}</title>
</Helmet>
<Margins>
<Stack className={styles.wrapper} direction="row" spacing={6}>
<Sidebar user={me} />
<Suspense fallback={<Loader />}>
<main className={styles.content}>{children}</main>
</Suspense>
</Stack>
</Margins>
</>
)
}
const useStyles = makeStyles((theme) => ({
wrapper: {
padding: theme.spacing(6, 0),
},
content: {
maxWidth: 800,
width: "100%",
},
}))
@@ -0,0 +1,133 @@
import { makeStyles } from "@material-ui/core/styles"
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
import { User } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack"
import { UserAvatar } from "components/UserAvatar/UserAvatar"
import { FC, ElementType, PropsWithChildren, ReactNode } from "react"
import { NavLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import AccountIcon from "@material-ui/icons/Person"
import SecurityIcon from "@material-ui/icons/LockOutlined"
const SidebarNavItem: FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
> = ({ children, href, icon }) => {
const styles = useStyles()
return (
<NavLink
to={href}
className={({ isActive }) =>
combineClasses([
styles.sidebarNavItem,
isActive ? styles.sidebarNavItemActive : undefined,
])
}
>
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
</NavLink>
)
}
const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
icon: Icon,
}) => {
const styles = useStyles()
return <Icon className={styles.sidebarNavItemIcon} />
}
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
const styles = useStyles()
return (
<nav className={styles.sidebar}>
<Stack direction="row" alignItems="center" className={styles.userInfo}>
<UserAvatar username={user.username} avatarURL={user.avatar_url} />
<Stack spacing={0} className={styles.userData}>
<span className={styles.username}>{user.username}</span>
<span className={styles.email}>{user.email}</span>
</Stack>
</Stack>
<SidebarNavItem
href="../account"
icon={<SidebarNavItemIcon icon={AccountIcon} />}
>
Account
</SidebarNavItem>
<SidebarNavItem
href="../security"
icon={<SidebarNavItemIcon icon={SecurityIcon} />}
>
Security
</SidebarNavItem>
<SidebarNavItem
href="../ssh-keys"
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
>
SSH Keys
</SidebarNavItem>
</nav>
)
}
const useStyles = makeStyles((theme) => ({
sidebar: {
width: 245,
flexShrink: 0,
},
sidebarNavItem: {
color: "inherit",
display: "block",
fontSize: 14,
textDecoration: "none",
padding: theme.spacing(1.5, 1.5, 1.5, 2),
borderRadius: theme.shape.borderRadius / 2,
transition: "background-color 0.15s ease-in-out",
marginBottom: 1,
position: "relative",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
sidebarNavItemActive: {
backgroundColor: theme.palette.action.hover,
"&:before": {
content: '""',
display: "block",
width: 3,
height: "100%",
position: "absolute",
left: 0,
top: 0,
backgroundColor: theme.palette.secondary.dark,
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
},
sidebarNavItemIcon: {
width: theme.spacing(2),
height: theme.spacing(2),
},
userInfo: {
marginBottom: theme.spacing(2),
},
userData: {
overflow: "hidden",
},
username: {
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
},
email: {
color: theme.palette.text.secondary,
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
},
}))
@@ -77,6 +77,9 @@ export const SecurityForm: FC<SecurityFormProps> = ({
)}
<TextField
{...getFieldHelpers("old_password")}
InputLabelProps={{
shrink: true,
}}
autoComplete="old_password"
fullWidth
label={Language.oldPasswordLabel}
@@ -85,6 +88,9 @@ export const SecurityForm: FC<SecurityFormProps> = ({
/>
<TextField
{...getFieldHelpers("password")}
InputLabelProps={{
shrink: true,
}}
autoComplete="password"
fullWidth
label={Language.newPasswordLabel}
@@ -93,6 +99,9 @@ export const SecurityForm: FC<SecurityFormProps> = ({
/>
<TextField
{...getFieldHelpers("confirm_password")}
InputLabelProps={{
shrink: true,
}}
autoComplete="confirm_password"
fullWidth
label={Language.confirmPasswordLabel}
@@ -1,6 +1,6 @@
import { useActor } from "@xstate/react"
import { useContext, FC } from "react"
import { Section } from "../../../components/Section/Section"
import { FC, useContext } from "react"
import { Section } from "../../../components/SettingsLayout/Section"
import { AccountForm } from "../../../components/SettingsAccountForm/SettingsAccountForm"
import { XServiceContext } from "../../../xServices/StateContext"
@@ -19,7 +19,7 @@ export const AccountPage: FC = () => {
}
return (
<Section title={Language.title}>
<Section title={Language.title} description="Update your account info">
<AccountForm
editable={Boolean(canEditUsers)}
email={me.email}
@@ -1,21 +1,12 @@
import { useActor } from "@xstate/react"
import { useContext, useEffect, PropsWithChildren, FC } from "react"
import { ConfirmDialog } from "../../../components/Dialogs/ConfirmDialog/ConfirmDialog"
import { Section } from "../../../components/Section/Section"
import { Section } from "../../../components/SettingsLayout/Section"
import { XServiceContext } from "../../../xServices/StateContext"
import { SSHKeysPageView } from "./SSHKeysPageView"
export const Language = {
title: "SSH keys",
description: (
<p>
The following public key is used to authenticate Git in workspaces. You
may add it to Git services (such as GitHub) that you need to access from
your workspace. <br />
<br />
Coder configures authentication via <code>$GIT_SSH_COMMAND</code>.
</p>
),
regenerateDialogTitle: "Regenerate SSH key?",
regenerateDialogMessage:
"You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces.",
@@ -41,7 +32,7 @@ export const SSHKeysPage: FC<PropsWithChildren<unknown>> = () => {
return (
<>
<Section title={Language.title} description={Language.description}>
<Section title={Language.title}>
<SSHKeysPageView
isLoading={isLoading}
hasLoaded={hasLoaded}
@@ -1,3 +1,4 @@
import { makeStyles } from "@material-ui/core/styles"
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import CircularProgress from "@material-ui/core/CircularProgress"
@@ -31,6 +32,8 @@ export const SSHKeysPageView: FC<
sshKey,
onRegenerateClick,
}) => {
const styles = useStyles()
if (isLoading) {
return (
<Box p={4}>
@@ -43,7 +46,6 @@ export const SSHKeysPageView: FC<
<Stack>
{/* Regenerating the key is not an option if getSSHKey fails.
Only one of the error messages will exist at a single time */}
{Boolean(getSSHKeyError) && (
<AlertBanner severity="error" error={getSSHKeyError} />
)}
@@ -57,6 +59,12 @@ export const SSHKeysPageView: FC<
)}
{hasLoaded && sshKey && (
<>
<p className={styles.description}>
The following public key is used to authenticate Git in workspaces.
You may add it to Git services (such as GitHub) that you need to
access from your workspace. Coder configures authentication via{" "}
<code className={styles.code}>$GIT_SSH_COMMAND</code>.
</p>
<CodeExample code={sshKey.public_key.trim()} />
<div>
<Button onClick={onRegenerateClick}>
@@ -68,3 +76,18 @@ export const SSHKeysPageView: FC<
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
description: {
fontSize: 14,
color: theme.palette.text.secondary,
margin: 0,
},
code: {
background: theme.palette.divider,
fontSize: 12,
padding: "2px 4px",
color: theme.palette.text.primary,
borderRadius: 2,
},
}))
@@ -2,7 +2,7 @@ import { useMachine } from "@xstate/react"
import { useMe } from "hooks/useMe"
import { FC } from "react"
import { userSecuritySettingsMachine } from "xServices/userSecuritySettings/userSecuritySettingsXService"
import { Section } from "../../../components/Section/Section"
import { Section } from "../../../components/SettingsLayout/Section"
import { SecurityForm } from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
export const Language = {
@@ -22,7 +22,7 @@ export const SecurityPage: FC = () => {
const { error } = securityState.context
return (
<Section title={Language.title}>
<Section title={Language.title} description="Update your account password">
<SecurityForm
updateSecurityError={error}
isLoading={securityState.matches("updatingSecurity")}