diff --git a/packages/web/components/templates/SettingsMenu.tsx b/packages/web/components/templates/SettingsMenu.tsx
new file mode 100644
index 000000000..333d39e88
--- /dev/null
+++ b/packages/web/components/templates/SettingsMenu.tsx
@@ -0,0 +1,227 @@
+import { ReactNode, useMemo } from 'react'
+import { Box, HStack, SpanBox, VStack } from '../elements/LayoutPrimitives'
+import { LIBRARY_LEFT_MENU_WIDTH } from './homeFeed/LibraryFilterMenu'
+import { LogoBox } from '../elements/LogoBox'
+import Link from 'next/link'
+import { styled, theme } from '../tokens/stitches.config'
+import { Button } from '../elements/Button'
+import { ArrowSquareUpRight } from 'phosphor-react'
+import { useRouter } from 'next/router'
+
+const HorizontalDivider = styled(SpanBox, {
+ width: '100%',
+ height: '1px',
+ my: '25px',
+ background: `${theme.colors.grayLine.toString()}`,
+})
+
+const StyledLink = styled(SpanBox, {
+ pl: '25px',
+ ml: '10px',
+ mb: '10px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '2px',
+ '&:hover': {
+ textDecoration: 'underline',
+ },
+
+ width: 'calc(100% - 10px)',
+ maxWidth: '100%',
+ height: '32px',
+
+ fontSize: '14px',
+ fontWeight: 'regular',
+ fontFamily: '$display',
+ color: '$thLibraryMenuUnselected',
+ verticalAlign: 'middle',
+ borderRadius: '3px',
+ cursor: 'pointer',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+})
+
+export function SettingsMenu(): JSX.Element {
+ const section1 = [
+ { name: 'Account', destination: '/settings/account' },
+ { name: 'API Keys', destination: '/settings/api' },
+ { name: 'Emails', destination: '/settings/emails' },
+ { name: 'Feeds', destination: '/settings/feeds' },
+ { name: 'Subscriptions', destination: '/settings/subscriptions' },
+ { name: 'Labels', destination: '/settings/labels' },
+ ]
+
+ const section2 = [
+ { name: 'Integrations', destination: '/settings/integrations' },
+ { name: 'Install', destination: '/settings/installation' },
+ ]
+ return (
+ <>
+
+
+
+
+
+
+ {section1.map((item) => {
+ return
+ })}
+
+ {section2.map((item) => {
+ return
+ })}
+
+
+
+
+ a': {
+ backgroundColor: 'transparent',
+ textDecoration: 'none',
+ },
+ }}
+ >
+
+
+ Documentation
+
+
+
+
+
+
+ {/* This spacer pushes library content to the right of
+ the fixed left side menu. */}
+
+ >
+ )
+}
+
+type SettingsButtonProps = {
+ name: string
+ destination: string
+}
+
+function SettingsButton(props: SettingsButtonProps): JSX.Element {
+ const router = useRouter()
+ const selected = useMemo(() => {
+ if (router && router.isReady) {
+ return router.asPath.endsWith(props.destination)
+ }
+ return false
+ }, [props, router])
+
+ return (
+
+
+ {props.name}
+
+
+ )
+}
diff --git a/packages/web/lib/networking/mutations/updateUserMutation.ts b/packages/web/lib/networking/mutations/updateUserMutation.ts
new file mode 100644
index 000000000..c94a4c30c
--- /dev/null
+++ b/packages/web/lib/networking/mutations/updateUserMutation.ts
@@ -0,0 +1,37 @@
+import { gql } from 'graphql-request'
+import { gqlFetcher } from '../networkHelpers'
+import { State } from '../fragments/articleFragment'
+
+export type UpdateUserInput = {
+ name: string
+ bio: string
+}
+
+export async function updateUserMutation(
+ input: UpdateUserInput
+): Promise {
+ const mutation = gql`
+ mutation UpdateUser($input: UpdateUserInput!) {
+ updateUser(input: $input) {
+ ... on UpdateUserSuccess {
+ user {
+ name
+ }
+ }
+ ... on UpdateUserError {
+ errorCodes
+ }
+ }
+ }
+ `
+
+ try {
+ const data = await gqlFetcher(mutation, {
+ input,
+ })
+ const output = data as any
+ return output.updateUser.user.name
+ } catch (err) {
+ return undefined
+ }
+}
diff --git a/packages/web/lib/networking/mutations/updateUserProfileMutation.ts b/packages/web/lib/networking/mutations/updateUserProfileMutation.ts
new file mode 100644
index 000000000..3fd33d6d0
--- /dev/null
+++ b/packages/web/lib/networking/mutations/updateUserProfileMutation.ts
@@ -0,0 +1,40 @@
+import { gql } from 'graphql-request'
+import { gqlFetcher } from '../networkHelpers'
+import { State } from '../fragments/articleFragment'
+
+export type UpdateUserProfileInput = {
+ userId: string
+ username: string
+}
+
+export async function updateUserProfileMutation(
+ input: UpdateUserProfileInput
+): Promise {
+ const mutation = gql`
+ mutation UpdateUserProfile($input: UpdateUserProfileInput!) {
+ updateUserProfile(input: $input) {
+ ... on UpdateUserProfileSuccess {
+ user {
+ profile {
+ username
+ }
+ }
+ }
+ ... on UpdateUserProfileError {
+ errorCodes
+ }
+ }
+ }
+ `
+
+ try {
+ const data = await gqlFetcher(mutation, {
+ input,
+ })
+ const output = data as any
+ console.log('output: ', output)
+ return output.updateUserProfile.user.profile.username
+ } catch (err) {
+ return undefined
+ }
+}
diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx
new file mode 100644
index 000000000..3869b8977
--- /dev/null
+++ b/packages/web/pages/settings/account.tsx
@@ -0,0 +1,274 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
+import { applyStoredTheme } from '../../lib/themeUpdater'
+import { useGetApiKeysQuery } from '../../lib/networking/queries/useGetApiKeysQuery'
+import { generateApiKeyMutation } from '../../lib/networking/mutations/generateApiKeyMutation'
+import { revokeApiKeyMutation } from '../../lib/networking/mutations/revokeApiKeyMutation'
+
+import {
+ FormInput,
+ FormInputProps,
+} from '../../components/elements/FormElements'
+import { FormModal } from '../../components/patterns/FormModal'
+import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
+import {
+ EmptySettingsRow,
+ SettingsTable,
+ SettingsTableRow,
+} from '../../components/templates/settings/SettingsTable'
+import { StyledText } from '../../components/elements/StyledText'
+import { formattedShortDate } from '../../lib/dateFormatting'
+import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
+import { SettingsLayout } from '../../components/templates/SettingsLayout'
+import { Toaster } from 'react-hot-toast'
+import {
+ Box,
+ SpanBox,
+ VStack,
+} from '../../components/elements/LayoutPrimitives'
+import { Button } from '../../components/elements/Button'
+import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery'
+import { updateUserMutation } from '../../lib/networking/mutations/updateUserMutation'
+import { updateUserProfileMutation } from '../../lib/networking/mutations/updateUserProfileMutation'
+import { styled } from '../../components/tokens/stitches.config'
+
+const StyledLabel = styled('label', {
+ fontWeight: 600,
+ fontSize: '16px',
+})
+
+export default function Account(): JSX.Element {
+ const { viewerData, isLoading } = useGetViewerQuery()
+ const [name, setName] = useState('')
+ const [username, setUsername] = useState('')
+ const [nameUpdating, setNameUpdating] = useState(false)
+ const [usernameUpdating, setUsernameUpdating] = useState(false)
+
+ const [debouncedUsername, setDebouncedUsername] = useState('')
+ const {
+ isUsernameValid,
+ usernameErrorMessage,
+ isLoading: isUsernameValidationLoading,
+ } = useValidateUsernameQuery({
+ username: debouncedUsername,
+ })
+
+ const usernameEdited = useMemo(() => {
+ return username !== viewerData?.me?.profile.username
+ }, [username, viewerData])
+
+ const usernameError = useMemo(() => {
+ return (
+ usernameEdited &&
+ username.length > 0 &&
+ usernameErrorMessage &&
+ !isUsernameValidationLoading
+ )
+ }, [
+ usernameEdited,
+ username,
+ usernameErrorMessage,
+ isUsernameValidationLoading,
+ ])
+
+ useEffect(() => {
+ if (viewerData?.me?.profile.username) {
+ setUsername(viewerData?.me?.profile.username)
+ }
+ }, [viewerData?.me?.profile.username])
+
+ useEffect(() => {
+ if (viewerData?.me?.name) {
+ setName(viewerData?.me?.name)
+ }
+ }, [viewerData?.me?.name])
+
+ const handleUsernameChange = useCallback(
+ (event: React.ChangeEvent): void => {
+ setUsername(event.target.value)
+ setTimeout(() => {
+ if (event.target.value) {
+ setDebouncedUsername(event.target.value)
+ }
+ }, 2000)
+ event.preventDefault()
+ },
+ []
+ )
+
+ const handleUpdateName = useCallback(() => {
+ setNameUpdating(true)
+ ;(async () => {
+ const updatedName = await updateUserMutation({ name, bio: '' })
+ if (updatedName) {
+ setName(updatedName)
+ showSuccessToast('Name updated')
+ } else {
+ showErrorToast('Error updating name')
+ }
+ setNameUpdating(false)
+ })()
+ }, [name, nameUpdating, setName, setNameUpdating])
+
+ const handleUpdateUsername = useCallback(() => {
+ setUsernameUpdating(true)
+
+ const userId = viewerData?.me?.id
+ if (!userId) {
+ showErrorToast('Error updating user info')
+ return
+ }
+
+ ;(async () => {
+ const updatedUsername = await updateUserProfileMutation({
+ userId,
+ username,
+ })
+ if (updatedUsername) {
+ setUsername(updatedUsername)
+ setDebouncedUsername(updatedUsername)
+ showSuccessToast('Username updated')
+ } else {
+ showErrorToast('Error updating username')
+ }
+ setUsernameUpdating(false)
+ })()
+ }, [
+ username,
+ usernameUpdating,
+ setUsername,
+ setUsernameUpdating,
+ viewerData?.me,
+ ])
+
+ applyStoredTheme(false)
+
+ return (
+
+
+
+
+
+
+
+ Account Details
+
+
+
+
+
+
+
+ Username
+
+
+
+
+
+ )
+}