mirror of
https://github.com/coder/coder.git
synced 2026-06-06 14:38:23 +00:00
refactor: User settings page (#5661)
This commit is contained in:
+31
-4
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user