diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx index 908efbfc52..2902070516 100644 --- a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx @@ -12,7 +12,7 @@ import { docs } from "#/utils/docs"; * All types of feature that we are currently supporting. Defined as record to * ensure that we can't accidentally make typos when writing the badge text. */ -export const featureStageBadgeTypes = { +const featureStageBadgeTypes = { early_access: "early access", beta: "beta", } as const satisfies Record; diff --git a/site/src/components/Icons/GitIcon.tsx b/site/src/components/Icons/GitIcon.tsx deleted file mode 100644 index 5345b748b2..0000000000 --- a/site/src/components/Icons/GitIcon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { ComponentProps, JSX } from "react"; - -export const GitIcon = (props: ComponentProps<"svg">): JSX.Element => ( - - - -); diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index 0af1baf8ac..41fd7aa22c 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -1,10 +1,14 @@ import type { FC } from "react"; import { useQuery } from "react-query"; import { groupsForUser } from "#/api/queries/groups"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { useAuthContext } from "#/contexts/auth/AuthProvider"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; -import { Section } from "../Section"; import { AccountForm } from "./AccountForm"; import { AccountUserGroups } from "./AccountUserGroups"; @@ -22,7 +26,13 @@ const AccountPage: FC = () => { return (
-
+
+ + Account + + Update your account info. + + { initialValues={{ username: me.username, name: me.name ?? "" }} onSubmit={updateProfile} /> -
+
{hasGroupsFeature && ( = ({ const { showOrganizations } = useDashboard(); return ( -
+
+ + + Your groups + + {groups && ( + You are in{" "} {groups.length} group {groups.length !== 1 && "s"} - - ) - } - > + + )} + +
{isApiError(error) && } @@ -63,6 +68,6 @@ export const AccountUserGroups: FC = ({ {loading && }
-
+ ); }; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx index 10ff71ab3d..aa299b6173 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx @@ -8,6 +8,10 @@ import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { PreviewBadge } from "#/components/Badges/Badges"; import { Label } from "#/components/Label/Label"; import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup"; +import { + SettingsHeader, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; import { DEFAULT_THEME } from "#/theme"; import { @@ -16,7 +20,6 @@ import { terminalFonts, } from "#/theme/constants"; import { cn } from "#/utils/cn"; -import { Section } from "../Section"; // Display Geist Mono (the default monospace font) first, then the rest // alphabetically. TerminalFontNames is auto-generated in alphabetical @@ -64,19 +67,17 @@ export const AppearanceForm: FC = ({ }; return ( -
+ {Boolean(error) && } -
+
+ + Theme -
- } - layout="fluid" - className="mb-12" - > + + +
= ({ onSelect={() => onChangeTheme("light")} />
-
-
+ + +
+ + Terminal Font -
- } - layout="fluid" - > + + + = ({ ))} -
+ ); }; diff --git a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx index 0535819bfc..33903dc811 100644 --- a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx +++ b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage.tsx @@ -9,7 +9,10 @@ import { } from "#/api/queries/externalAuth"; import type { ExternalAuthLinkProvider } from "#/api/typesGenerated"; import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; -import { Section } from "../Section"; +import { + SettingsHeader, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { ExternalAuthPageView } from "./ExternalAuthPageView"; const ExternalAuthPage: FC = () => { @@ -24,7 +27,10 @@ const ExternalAuthPage: FC = () => { const validateAppMutation = useMutation(validateExternalAuth(queryClient)); return ( -
+ <> + + External Authentication + { } }} /> -
+ ); }; diff --git a/site/src/pages/UserSettingsPage/Layout.tsx b/site/src/pages/UserSettingsPage/Layout.tsx index 5433716ff6..86c0dfb291 100644 --- a/site/src/pages/UserSettingsPage/Layout.tsx +++ b/site/src/pages/UserSettingsPage/Layout.tsx @@ -1,7 +1,12 @@ import { type FC, Suspense } from "react"; import { Outlet } from "react-router"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, +} from "#/components/Breadcrumb/Breadcrumb"; import { Loader } from "#/components/Loader/Loader"; -import { Margins } from "#/components/Margins/Margins"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { pageTitle } from "#/utils/page"; import { Sidebar } from "./Sidebar"; @@ -13,16 +18,28 @@ const Layout: FC = () => { <> {pageTitle("Settings")} - -
- - }> -
- +
+ + + + + User Settings + + + + +
+
+
+ +
+ }> + +
- -
- +
+ +
); }; diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 3600af6aae..967df0c058 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -18,6 +18,11 @@ import { } from "#/api/queries/users"; import type { NotificationTemplate } from "#/api/typesGenerated"; import { Loader } from "#/components/Loader/Loader"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { Switch } from "#/components/Switch/Switch"; import { Tooltip, @@ -35,7 +40,6 @@ import { } from "#/modules/notifications/utils"; import type { Permissions } from "#/modules/permissions"; import { pageTitle } from "#/utils/page"; -import { Section } from "../Section"; const NotificationsPage: FC = () => { const { user, permissions } = useAuthenticated(); @@ -111,156 +115,157 @@ const NotificationsPage: FC = () => { <> {pageTitle("Notifications Settings")} -
- {ready ? ( -
- {Object.entries(allTemplatesByGroup).map(([group, templates]) => { - if (!canSeeNotificationGroup(group, permissions)) { - return null; - } + + Notifications + + Control which notifications you receive. + + - const allDisabled = templates.some((tpl) => { - return notificationIsDisabled(disabledPreferences.data, tpl); - }); + {ready ? ( +
+ {Object.entries(allTemplatesByGroup).map(([group, templates]) => { + if (!canSeeNotificationGroup(group, permissions)) { + return null; + } - return ( -
-
-
-
- { - const updated = { ...disabledPreferences.data }; - for (const tpl of templates) { - updated[tpl.id] = !checked; - } - await updatePreferences.mutateAsync( - { - template_disabled_map: updated, + const allDisabled = templates.some((tpl) => { + return notificationIsDisabled(disabledPreferences.data, tpl); + }); + + return ( +
+
+
+
+ { + const updated = { ...disabledPreferences.data }; + for (const tpl of templates) { + updated[tpl.id] = !checked; + } + await updatePreferences.mutateAsync( + { + template_disabled_map: updated, + }, + { + onSuccess: () => { + toast.success( + "Notification preferences updated.", + ); }, - { - onSuccess: () => { - toast.success( - "Notification preferences updated.", - ); - }, - onError: (error) => { - toast.error( - "Error updating notification preferences.", - { - description: getErrorDetail(error), - }, - ); - }, + onError: (error) => { + toast.error( + "Error updating notification preferences.", + { + description: getErrorDetail(error), + }, + ); }, - ); - }} - /> -
- -
- {templates.map((tmpl) => { - const method = castNotificationMethod( - tmpl.method || dispatchMethods.data.default, - ); - const Icon = methodIcons[method]; - const label = methodLabels[method]; + }, + ); + }} + /> +
+ +
+ {templates.map((tmpl) => { + const method = castNotificationMethod( + tmpl.method || dispatchMethods.data.default, + ); + const Icon = methodIcons[method]; + const label = methodLabels[method]; - const disabled = notificationIsDisabled( - disabledPreferences.data, - tmpl, - ); + const disabled = notificationIsDisabled( + disabledPreferences.data, + tmpl, + ); - return ( - -
-
- { - await updatePreferences.mutateAsync( - { - template_disabled_map: { - ...disabledPreferences.data, - [tmpl.id]: !checked, - }, + return ( + +
+
+ { + await updatePreferences.mutateAsync( + { + template_disabled_map: { + ...disabledPreferences.data, + [tmpl.id]: !checked, }, - { - onSuccess: () => { - toast.success( - "Notification preferences updated.", - ); - }, - onError: (error) => { - toast.error( - "Error updating notification preferences.", - { - description: getErrorDetail(error), - }, - ); - }, + }, + { + onSuccess: () => { + toast.success( + "Notification preferences updated.", + ); }, - ); + onError: (error) => { + toast.error( + "Error updating notification preferences.", + { + description: getErrorDetail(error), + }, + ); + }, + }, + ); - // Clear the Tasks page warning dismissal when enabling a task notification - // This ensures that if the user disables task notifications again later, - // they will see the warning alert again. - if ( - isTaskNotification(tmpl) && - checked && - preferencesQuery.data - ) { - updatePreferencesMutation.mutate({ - ...preferencesQuery.data, - task_notification_alert_dismissed: false, - }); - } - }} - /> - -
- - - - - - - Delivery via {label} - - + // Clear the Tasks page warning dismissal when enabling a task notification + // This ensures that if the user disables task notifications again later, + // they will see the warning alert again. + if ( + isTaskNotification(tmpl) && + checked && + preferencesQuery.data + ) { + updatePreferencesMutation.mutate({ + ...preferencesQuery.data, + task_notification_alert_dismissed: false, + }); + } + }} + /> +
-
- ); - })} -
-
- ); - })} -
- ) : ( - - )} -
+ + + + + + + Delivery via {label} + + +
+ + ); + })} +
+ + ); + })} + + ) : ( + + )} ); }; diff --git a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx index 70964a500f..752c9ad76a 100644 --- a/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx +++ b/site/src/pages/UserSettingsPage/OAuth2ProviderPage/OAuth2ProviderPage.tsx @@ -4,8 +4,11 @@ import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { getApps, revokeApp } from "#/api/queries/oauth2"; import { DeleteDialog } from "#/components/Dialogs/DeleteDialog/DeleteDialog"; +import { + SettingsHeader, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { Section } from "../Section"; import OAuth2ProviderPageView from "./OAuth2ProviderPageView"; const OAuth2ProviderPage: FC = () => { @@ -19,7 +22,10 @@ const OAuth2ProviderPage: FC = () => { ); return ( -
+ <> + + OAuth2 Applications + { }} /> )} -
+ ); }; diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx index 899f56a92b..faec8cd3f0 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx @@ -4,7 +4,10 @@ import { toast } from "sonner"; import { getErrorDetail, getErrorMessage } from "#/api/errors"; import { regenerateUserSSHKey, userSSHKey } from "#/api/queries/sshKeys"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { Section } from "../Section"; +import { + SettingsHeader, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { SSHKeysPageView } from "./SSHKeysPageView"; const SSHKeysPage: FC = () => { @@ -19,14 +22,15 @@ const SSHKeysPage: FC = () => { return ( <> -
- setIsConfirmingRegeneration(true)} - /> -
+ + SSH keys + + setIsConfirmingRegeneration(true)} + /> { @@ -38,11 +42,14 @@ const SchedulePage: FC = () => { } return ( -
+ <> + + Quiet hours + + Workspaces may be automatically updated during your quiet hours, as + configured by your administrators. + + { }); }} /> -
+ ); }; diff --git a/site/src/pages/UserSettingsPage/Section.tsx b/site/src/pages/UserSettingsPage/Section.tsx deleted file mode 100644 index 2fc39ef4ba..0000000000 --- a/site/src/pages/UserSettingsPage/Section.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { FC, ReactNode } from "react"; -import { - FeatureStageBadge, - type featureStageBadgeTypes, -} from "#/components/FeatureStageBadge/FeatureStageBadge"; - -type SectionLayout = "fixed" | "fluid"; - -interface SectionProps { - // Useful for testing - id?: string; - title?: ReactNode | string; - description?: ReactNode; - toolbar?: ReactNode; - alert?: ReactNode; - layout?: SectionLayout; - className?: string; - children?: ReactNode; - featureStage?: keyof typeof featureStageBadgeTypes; -} - -const DESCRIPTION_CLASS = - "text-content-secondary text-base m-0 mt-1 leading-normal"; - -export const Section: FC = ({ - id, - title, - description, - toolbar, - alert, - className = "", - children, - layout = "fixed", - featureStage, -}) => { - return ( -
-
- {(title || description) && ( -
-
- {title && ( -
-

{title}

- {featureStage && ( - - )} -
- )} - {description && typeof description === "string" && ( -

{description}

- )} - {description && typeof description !== "string" && ( -
{description}
- )} -
- {toolbar &&
{toolbar}
} -
- )} - {alert &&
{alert}
} - {children} -
-
- ); -}; diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityForm.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityForm.tsx index d4f4dbb4f1..29a97ade80 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SecurityForm.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityForm.tsx @@ -7,6 +7,11 @@ import { Button } from "#/components/Button/Button"; import { Form, FormFields } from "#/components/Form/Form"; import { FormField } from "#/components/FormField/FormField"; import { PasswordField } from "#/components/PasswordField/PasswordField"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { Spinner } from "#/components/Spinner/Spinner"; import { getFormHelpers } from "#/utils/formUtils"; @@ -64,34 +69,44 @@ export const SecurityForm: FC = ({ } return ( -
- - {Boolean(error) && } - - - + <> + + + Password + + + Update your account password. + + + + + {Boolean(error) && } + + + -
- -
-
- +
+ +
+
+ + ); }; diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx index 17470cd474..61fc3a7977 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SecurityPage.tsx @@ -4,8 +4,11 @@ import { toast } from "sonner"; import { API } from "#/api/api"; import { authMethods, updatePassword } from "#/api/queries/users"; import { Loader } from "#/components/Loader/Loader"; +import { + SettingsHeader, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { useAuthenticated } from "#/hooks/useAuthenticated"; -import { Section } from "../Section"; import { SecurityForm } from "./SecurityForm"; import { SingleSignOnSection, @@ -71,9 +74,12 @@ export const SecurityPageView: FC = ({ }) => { return (
-
+
+ + Security + -
+
{oidc && } ); diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx index 997132f3cf..a0b6db2324 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SingleSignOnSection.tsx @@ -15,8 +15,12 @@ import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { EmptyState } from "#/components/EmptyState/EmptyState"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { docs } from "#/utils/docs"; -import { Section } from "../Section"; type LoginTypeConfirmation = | { @@ -134,65 +138,68 @@ export const SingleSignOnSection: FC = ({ const noSsoEnabled = !authMethods.github.enabled && !authMethods.oidc.enabled; return ( - <> -
-
- {userLoginType.login_type === "password" ? ( - <> - {authMethods.github.enabled && ( - - )} +
+ + + Single Sign On + + + Authenticate in Coder using one-click. + + - {authMethods.oidc.enabled && ( - - )} +
+ {userLoginType.login_type === "password" ? ( + <> + {authMethods.github.enabled && ( + + )} - {noSsoEnabled && } - - ) : ( -
- - - Authenticated with{" "} - - {userLoginType.login_type === "github" - ? "GitHub" - : getOIDCLabel(authMethods.oidc)} - - -
- {userLoginType.login_type === "github" ? ( - - ) : ( - - )} -
+ {authMethods.oidc.enabled && ( + + )} + + {noSsoEnabled && } + + ) : ( +
+ + + Authenticated with{" "} + + {userLoginType.login_type === "github" + ? "GitHub" + : getOIDCLabel(authMethods.oidc)} + + +
+ {userLoginType.login_type === "github" ? ( + + ) : ( + + )}
- )} -
-
+ + )} + = ({ onClose={closeConfirmation} onConfirm={confirm} /> - + ); }; diff --git a/site/src/pages/UserSettingsPage/Sidebar.tsx b/site/src/pages/UserSettingsPage/Sidebar.tsx index 223ae79e58..9017a37416 100644 --- a/site/src/pages/UserSettingsPage/Sidebar.tsx +++ b/site/src/pages/UserSettingsPage/Sidebar.tsx @@ -1,21 +1,10 @@ -import { - BellIcon, - BrushIcon, - CalendarCogIcon, - FingerprintIcon, - KeyIcon, - LockIcon, - ShieldIcon, - UserIcon, -} from "lucide-react"; import type { FC } from "react"; import type { User } from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; -import { GitIcon } from "#/components/Icons/GitIcon"; import { Sidebar as BaseSidebar, + SettingsSidebarNavItem, SidebarHeader, - SidebarNavItem, } from "#/components/Sidebar/Sidebar"; import { useDashboard } from "#/modules/dashboard/useDashboard"; import { getPrereleaseFlag } from "#/utils/buildInfo"; @@ -28,6 +17,8 @@ export const Sidebar: FC = ({ user }) => { const { entitlements, experiments, buildInfo } = useDashboard(); const showSchedulePage = entitlements.features.advanced_template_scheduling.enabled; + const showOAuth2Page = + experiments.includes("oauth2") || getPrereleaseFlag(buildInfo) === "devel"; return ( @@ -36,38 +27,35 @@ export const Sidebar: FC = ({ user }) => { title={user.username} subtitle={user.email} /> - - Account - - - Appearance - - - External Authentication - - {(experiments.includes("oauth2") || - getPrereleaseFlag(buildInfo) === "devel") && ( - - OAuth2 Applications - - )} - {showSchedulePage && ( - - Schedule - - )} - - Security - - - SSH Keys - - - Tokens - - - Notifications - +
+ Account + + Appearance + + + External Authentication + + {showOAuth2Page && ( + + OAuth2 Applications + + )} + {showSchedulePage && ( + + Schedule + + )} + + Security + + + SSH Keys + + Tokens + + Notifications + +
); }; diff --git a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx index 69c155cb13..f0a90d2e89 100644 --- a/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx +++ b/site/src/pages/UserSettingsPage/TokensPage/TokensPage.tsx @@ -3,8 +3,11 @@ import { type FC, useState } from "react"; import { Link as RouterLink } from "react-router"; import type { APIKeyWithOwner } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; -import { cn } from "#/utils/cn"; -import { Section } from "../Section"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; import { useTokensData } from "./hooks"; import { TokensPageView } from "./TokensPageView"; @@ -31,32 +34,35 @@ const TokensPage: FC = () => { return ( <> -
- Tokens are used to authenticate with the Coder API. You can create a - token with the Coder CLI using the {cliCreateCommand}{" "} - command. - + + + + Add token + + } - layout="fluid" > - - { - setTokenToDelete(token); - }} - /> -
+ Tokens + + Tokens are used to authenticate with the Coder API. You can create a + token with the Coder CLI using the{" "} + + {cliCreateCommand} + {" "} + command. + + + { + setTokenToDelete(token); + }} + /> { ); }; -const TokenActions: FC = () => ( -
- -
-); - export default TokensPage;