Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow users update their account email address with proper validation. #4520

Merged
merged 2 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions clients/apps/web/src/app/(main)/verify-email/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import LogoIcon from '@/components/Brand/LogoIcon'
import { CONFIG } from '@/utils/config'
import { Metadata } from 'next'
import { redirect } from 'next/navigation'
import Button from 'polarkit/components/ui/atoms/button'

export const metadata: Metadata = {
title: 'Email Update confirmation',
}

export default function Page({
searchParams: { token, return_to },
}: {
searchParams: { token: string; return_to?: string }
}) {
const urlSearchParams = new URLSearchParams({
...(return_to && { return_to }),
})
const handleSubmit = async (formData: FormData) => {
'use server'
try {
await fetch(
`${CONFIG.BASE_URL}/v1/email-update/verify${urlSearchParams}`,
{
method: 'POST',
body: formData,
},
)
} catch (error) {
console.error(error)
}
redirect('/settings?update_email=verified')
}

return (
<form
className="dark:bg-polar-950 flex h-screen w-full grow items-center justify-center bg-gray-50"
action={handleSubmit}
>
<div id="polar-bg-gradient"></div>
<div className="flex w-80 flex-col items-center gap-4">
<LogoIcon size={60} className="mb-6 text-blue-500 dark:text-blue-400" />
<div className="dark:text-polar-400 text-center text-gray-500">
To complete the email update process, please click the button below:
</div>
<input type="hidden" name="token" value={token} />
<Button fullWidth size="lg" type="submit">
Update the email
</Button>
</div>
</form>
)
}
98 changes: 98 additions & 0 deletions clients/apps/web/src/components/Form/EmailUpdateForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client'

import { useSendEmailUpdate } from '@/hooks/emailUpdate'
import { setValidationErrors } from '@/utils/api/errors'
import { FormControl } from '@mui/material'
import { ResponseError, ValidationError } from '@polar-sh/sdk'
import Button from 'polarkit/components/ui/atoms/button'
import Input from 'polarkit/components/ui/atoms/input'

import { Form, FormField, FormItem } from 'polarkit/components/ui/form'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'

interface EmailUpdateformProps {
returnTo?: string
onEmailUpdateRequest?: () => void
onEmailUpdateExists?: () => void
onEmailUpdateForm?: () => void
setErr?: (value: string | null) => void
}

const EmailUpdateForm: React.FC<EmailUpdateformProps> = ({
returnTo,
onEmailUpdateRequest,
onEmailUpdateExists,
onEmailUpdateForm,
setErr,
}) => {
const form = useForm<{ email: string }>()
const { control, handleSubmit, setError } = form
const [loading, setLoading] = useState(false)
const sendEmailUpdate = useSendEmailUpdate()

const onSubmit: SubmitHandler<{ email: string }> = async ({ email }) => {
setLoading(true)
try {
await sendEmailUpdate(email, returnTo)
onEmailUpdateRequest?.()
} catch (e) {
if (e instanceof ResponseError) {
const body = await e.response.json()
if (e.response.status === 422) {
const validationErrors = body['detail'] as ValidationError[]
if (setErr) setErr(body['detail'][0].msg)
onEmailUpdateExists?.()
setTimeout(() => {
onEmailUpdateForm?.()
}, 6000)
setValidationErrors(validationErrors, setError)
} else if (body['detail']) {
setError('email', { message: body['detail'] })
}
}
} finally {
setLoading(false)
}
}

return (
<Form {...form}>
<form className="flex w-full flex-col" onSubmit={handleSubmit(onSubmit)}>
<FormField
control={control}
name="email"
render={({ field }) => {
return (
<FormItem>
<FormControl className="w-full">
<div className="flex w-full flex-row gap-2">
<Input
type="email"
required
placeholder="New email"
autoComplete="off"
data-1p-ignore
{...field}
/>
<Button
type="submit"
size="lg"
variant="secondary"
loading={loading}
disabled={loading}
>
Update
</Button>
</div>
</FormControl>
</FormItem>
)
}}
/>
</form>
</Form>
)
}

export default EmailUpdateForm
77 changes: 66 additions & 11 deletions clients/apps/web/src/components/Settings/AuthenticationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { useAuth, useGitHubAccount, useGoogleAccount } from '@/hooks'
import { getGitHubAuthorizeURL, getGoogleAuthorizeURL } from '@/utils/auth'
import { AlternateEmailOutlined, GitHub, Google } from '@mui/icons-material'
import { OAuthAccountRead } from '@polar-sh/sdk'
import { usePathname } from 'next/navigation'
import { usePathname, useSearchParams } from 'next/navigation'
import {
FormattedDateTime,
ShadowListGroup,
} from 'polarkit/components/ui/atoms'
import Button from 'polarkit/components/ui/atoms/button'
import { useEffect, useState } from 'react'
import EmailUpdateForm from '../Form/EmailUpdateForm'

interface AuthenticationMethodProps {
icon: React.ReactNode
Expand Down Expand Up @@ -135,11 +137,72 @@ const GoogleAuthenticationMethod: React.FC<GoogleAuthenticationMethodProps> = ({
}

const AuthenticationSettings = () => {
const { currentUser } = useAuth()
const { currentUser, reloadUser } = useAuth()
const pathname = usePathname()
const githubAccount = useGitHubAccount()
const googleAccount = useGoogleAccount()

const searchParams = useSearchParams()
const [updateEmailStage, setUpdateEmailStage] = useState<
'off' | 'form' | 'request' | 'verified' | 'exists'
>((searchParams.get('update_email') as 'verified' | null) || 'off')
const [userReloaded, setUserReloaded] = useState(false)
const [errMsg, setErrMsg] = useState<string | null>(null)

useEffect(() => {
if (!userReloaded && updateEmailStage === 'verified') {
reloadUser()
setUserReloaded(true)
}
}, [updateEmailStage, reloadUser, userReloaded])

const updateEmailContent: Record<
'off' | 'form' | 'request' | 'verified' | 'exists',
React.ReactNode
> = {
off: (
<div className="flex flex-row items-center gap-2">
{currentUser && (
<>
<div className="text-sm">
Connected{' '}
<FormattedDateTime
datetime={currentUser?.created_at}
dateStyle="medium"
/>
</div>
<Button onClick={() => setUpdateEmailStage('form')}>
Change email
</Button>
</>
)}
</div>
),
form: (
<EmailUpdateForm
onEmailUpdateRequest={() => setUpdateEmailStage('request')}
onEmailUpdateExists={() => setUpdateEmailStage('exists')}
onEmailUpdateForm={() => setUpdateEmailStage('form')}
setErr={setErrMsg}
/>
),
request: (
<div className="dark:text-polar-400 text-center text-sm text-gray-500">
A verification email was sent to this address.
</div>
),
verified: (
<div className="text-center text-sm text-green-700 dark:text-green-500">
Your email has been updated!
</div>
),
exists: (
<div className="text-center text-sm text-red-700 dark:text-red-500">
{errMsg}
</div>
),
}

return (
<>
{currentUser && (
Expand All @@ -161,15 +224,7 @@ const AuthenticationSettings = () => {
icon={<AlternateEmailOutlined />}
title={currentUser.email}
subtitle="You can sign in with magic links sent to your email."
action={
<div className="text-sm">
Connected{' '}
<FormattedDateTime
datetime={currentUser.created_at}
dateStyle="medium"
/>
</div>
}
action={updateEmailContent[updateEmailStage]}
/>
</ShadowListGroup.Item>
</ShadowListGroup>
Expand Down
82 changes: 44 additions & 38 deletions clients/apps/web/src/components/Settings/GeneralSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ExpandMoreOutlined } from '@mui/icons-material'
import { ShadowListGroup } from 'polarkit/components/ui/atoms'
import Button from 'polarkit/components/ui/atoms/button'
import {
DropdownMenu,
Expand All @@ -8,13 +9,16 @@ import {
} from 'polarkit/components/ui/dropdown-menu'
import { useCallback, useEffect, useRef, useState } from 'react'
import Spinner from '../Shared/Spinner'

export type Theme = 'system' | 'light' | 'dark'

const GeneralSettings = () => {
const [theme, setTheme] = useState<Theme | undefined>()
interface GeneralSettingsProps {
returnTo?: string
}

const GeneralSettings: React.FC<GeneralSettingsProps> = () => {
const [theme, setTheme] = useState<Theme | undefined>()
const didSetTheme = useRef(false)

const onInitialLoad = () => {
if (didSetTheme.current) {
return
Expand Down Expand Up @@ -61,42 +65,44 @@ const GeneralSettings = () => {
}, [])

return (
<div className="dark:text-polar-200 dark:border-polar-700 dark:bg-polar-900 flex w-full flex-col divide-y rounded-2xl border p-4 text-gray-900">
<div className="flex flex-row items-start justify-between">
<div className="flex flex-col gap-y-1">
<h3>Theme</h3>
<p className="dark:text-polar-500 text-sm text-gray-400">
Override your browser&apos;s preferred theme settings
</p>
<ShadowListGroup>
<ShadowListGroup.Item>
<div className="flex flex-row items-start justify-between">
<div className="flex flex-col gap-y-1">
<h3>Theme</h3>
<p className="dark:text-polar-500 text-sm text-gray-400">
Override your browser&apos;s preferred theme settings
</p>
</div>
{theme === undefined ? (
<Spinner />
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="justify-between" variant="secondary">
<span className="capitalize">{theme}</span>
<ExpandMoreOutlined className="ml-2" fontSize="small" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="dark:bg-polar-800 bg-gray-50 shadow-lg"
align="end"
>
<DropdownMenuItem onClick={handleThemeChange('system')}>
<span>System</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleThemeChange('light')}>
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleThemeChange('dark')}>
<span>Dark</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{theme === undefined ? (
<Spinner />
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="justify-between" variant="secondary">
<span className="capitalize">{theme}</span>
<ExpandMoreOutlined className="ml-2" fontSize="small" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="dark:bg-polar-800 bg-gray-50 shadow-lg"
align="end"
>
<DropdownMenuItem onClick={handleThemeChange('system')}>
<span>System</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleThemeChange('light')}>
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleThemeChange('dark')}>
<span>Dark</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</ShadowListGroup.Item>
</ShadowListGroup>
)
}

Expand Down
21 changes: 21 additions & 0 deletions clients/apps/web/src/hooks/emailUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client'

import { api } from "@/utils/api"
import { EmailUpdateRequest } from "@polar-sh/sdk"
import { useRouter } from "next/navigation"
import { useCallback } from "react"

export const useSendEmailUpdate = () => {
const router = useRouter()
const func = useCallback(
async (email: string, return_to?: string) => {
const body: EmailUpdateRequest = {
email,
return_to,
}
await api.emailUpdate.requestEmailUpdate({ body })
},
[router],
)
return func
}
1 change: 1 addition & 0 deletions clients/packages/sdk/src/client/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ apis/CustomerPortalSubscriptionsApi.ts
apis/CustomersApi.ts
apis/DashboardApi.ts
apis/DiscountsApi.ts
apis/EmailUpdateApi.ts
apis/EmbedsApi.ts
apis/ExternalOrganizationsApi.ts
apis/FilesApi.ts
Expand Down
Loading
Loading