diff --git a/clients/apps/web/src/app/(main)/verify-email/page.tsx b/clients/apps/web/src/app/(main)/verify-email/page.tsx new file mode 100644 index 0000000000..89c1b85900 --- /dev/null +++ b/clients/apps/web/src/app/(main)/verify-email/page.tsx @@ -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 ( +
+
+
+ +
+ To complete the email update process, please click the button below: +
+ + +
+
+ ) +} diff --git a/clients/apps/web/src/components/Form/EmailUpdateForm.tsx b/clients/apps/web/src/components/Form/EmailUpdateForm.tsx new file mode 100644 index 0000000000..fa12bc0e60 --- /dev/null +++ b/clients/apps/web/src/components/Form/EmailUpdateForm.tsx @@ -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 = ({ + 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 ( +
+ + { + return ( + + +
+ + +
+
+
+ ) + }} + /> + + + ) +} + +export default EmailUpdateForm diff --git a/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx b/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx index 9c6e83b911..e5cabdf60f 100644 --- a/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx +++ b/clients/apps/web/src/components/Settings/AuthenticationSettings.tsx @@ -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 @@ -135,11 +137,72 @@ const GoogleAuthenticationMethod: React.FC = ({ } 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(null) + + useEffect(() => { + if (!userReloaded && updateEmailStage === 'verified') { + reloadUser() + setUserReloaded(true) + } + }, [updateEmailStage, reloadUser, userReloaded]) + + const updateEmailContent: Record< + 'off' | 'form' | 'request' | 'verified' | 'exists', + React.ReactNode + > = { + off: ( +
+ {currentUser && ( + <> +
+ Connected{' '} + +
+ + + )} +
+ ), + form: ( + setUpdateEmailStage('request')} + onEmailUpdateExists={() => setUpdateEmailStage('exists')} + onEmailUpdateForm={() => setUpdateEmailStage('form')} + setErr={setErrMsg} + /> + ), + request: ( +
+ A verification email was sent to this address. +
+ ), + verified: ( +
+ Your email has been updated! +
+ ), + exists: ( +
+ {errMsg} +
+ ), + } + return ( <> {currentUser && ( @@ -161,15 +224,7 @@ const AuthenticationSettings = () => { icon={} title={currentUser.email} subtitle="You can sign in with magic links sent to your email." - action={ -
- Connected{' '} - -
- } + action={updateEmailContent[updateEmailStage]} /> diff --git a/clients/apps/web/src/components/Settings/GeneralSettings.tsx b/clients/apps/web/src/components/Settings/GeneralSettings.tsx index b18f99028b..ed45a5c9c9 100644 --- a/clients/apps/web/src/components/Settings/GeneralSettings.tsx +++ b/clients/apps/web/src/components/Settings/GeneralSettings.tsx @@ -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, @@ -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() +interface GeneralSettingsProps { + returnTo?: string +} +const GeneralSettings: React.FC = () => { + const [theme, setTheme] = useState() const didSetTheme = useRef(false) + const onInitialLoad = () => { if (didSetTheme.current) { return @@ -61,42 +65,44 @@ const GeneralSettings = () => { }, []) return ( -
-
-
-

Theme

-

- Override your browser's preferred theme settings -

+ + +
+
+

Theme

+

+ Override your browser's preferred theme settings +

+
+ {theme === undefined ? ( + + ) : ( + + + + + + + System + + + Light + + + Dark + + + + )}
- {theme === undefined ? ( - - ) : ( - - - - - - - System - - - Light - - - Dark - - - - )} -
-
+ + ) } diff --git a/clients/apps/web/src/hooks/emailUpdate.ts b/clients/apps/web/src/hooks/emailUpdate.ts new file mode 100644 index 0000000000..13e0810cc6 --- /dev/null +++ b/clients/apps/web/src/hooks/emailUpdate.ts @@ -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 +} diff --git a/clients/packages/sdk/src/client/.openapi-generator/FILES b/clients/packages/sdk/src/client/.openapi-generator/FILES index 51b4a13f2e..3430f1c4d2 100644 --- a/clients/packages/sdk/src/client/.openapi-generator/FILES +++ b/clients/packages/sdk/src/client/.openapi-generator/FILES @@ -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 diff --git a/clients/packages/sdk/src/client/PolarAPI.ts b/clients/packages/sdk/src/client/PolarAPI.ts index 6513a7b94d..4cdaaf8066 100644 --- a/clients/packages/sdk/src/client/PolarAPI.ts +++ b/clients/packages/sdk/src/client/PolarAPI.ts @@ -9,6 +9,7 @@ import { Configuration, DashboardApi, DiscountsApi, + EmailUpdateApi, ExternalOrganizationsApi, FilesApi, FundingApi, @@ -71,6 +72,7 @@ export class PolarAPI { public readonly discounts: DiscountsApi public readonly benefits: BenefitsApi public readonly dashboard: DashboardApi + public readonly emailUpdate: EmailUpdateApi public readonly externalOrganizations: ExternalOrganizationsApi public readonly funding: FundingApi public readonly integrationsDiscord: IntegrationsDiscordApi @@ -120,6 +122,7 @@ export class PolarAPI { this.benefits = new BenefitsApi(config) this.dashboard = new DashboardApi(config) this.discounts = new DiscountsApi(config) + this.emailUpdate = new EmailUpdateApi(config) this.externalOrganizations = new ExternalOrganizationsApi(config) this.funding = new FundingApi(config) this.integrationsDiscord = new IntegrationsDiscordApi(config) diff --git a/clients/packages/sdk/src/client/apis/EmailUpdateApi.ts b/clients/packages/sdk/src/client/apis/EmailUpdateApi.ts new file mode 100644 index 0000000000..5a1100ced0 --- /dev/null +++ b/clients/packages/sdk/src/client/apis/EmailUpdateApi.ts @@ -0,0 +1,144 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Polar API + * Read the docs at https://docs.polar.sh/api + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import * as runtime from '../runtime'; +import type { + EmailUpdateRequest, + HTTPValidationError, +} from '../models/index'; + +export interface EmailUpdateApiRequestEmailUpdateRequest { + body: EmailUpdateRequest; +} + +export interface EmailUpdateApiVerifyEmailUpdateRequest { + token: string; + returnTo?: string; +} + +/** + * + */ +export class EmailUpdateApi extends runtime.BaseAPI { + + /** + * Request Email Update + */ + async requestEmailUpdateRaw(requestParameters: EmailUpdateApiRequestEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['body'] == null) { + throw new runtime.RequiredError( + 'body', + 'Required parameter "body" was null or undefined when calling requestEmailUpdate().' + ); + } + + const queryParameters: any = {}; + + const headerParameters: runtime.HTTPHeaders = {}; + + headerParameters['Content-Type'] = 'application/json'; + + if (this.configuration && this.configuration.accessToken) { + const token = this.configuration.accessToken; + const tokenString = await token("pat", []); + + if (tokenString) { + headerParameters["Authorization"] = `Bearer ${tokenString}`; + } + } + const response = await this.request({ + path: `/v1/email-update/request`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: requestParameters['body'], + }, initOverrides); + + if (this.isJsonMime(response.headers.get('content-type'))) { + return new runtime.JSONApiResponse(response); + } else { + return new runtime.TextApiResponse(response) as any; + } + } + + /** + * Request Email Update + */ + async requestEmailUpdate(requestParameters: EmailUpdateApiRequestEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.requestEmailUpdateRaw(requestParameters, initOverrides); + return await response.value(); + } + + /** + * Verify Email Update + */ + async verifyEmailUpdateRaw(requestParameters: EmailUpdateApiVerifyEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (requestParameters['token'] == null) { + throw new runtime.RequiredError( + 'token', + 'Required parameter "token" was null or undefined when calling verifyEmailUpdate().' + ); + } + + const queryParameters: any = {}; + + if (requestParameters['returnTo'] != null) { + queryParameters['return_to'] = requestParameters['returnTo']; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const consumes: runtime.Consume[] = [ + { contentType: 'application/x-www-form-urlencoded' }, + ]; + // @ts-ignore: canConsumeForm may be unused + const canConsumeForm = runtime.canConsumeForm(consumes); + + let formParams: { append(param: string, value: any): any }; + let useForm = false; + if (useForm) { + formParams = new FormData(); + } else { + formParams = new URLSearchParams(); + } + + if (requestParameters['token'] != null) { + formParams.append('token', requestParameters['token'] as any); + } + + const response = await this.request({ + path: `/v1/email-update/verify`, + method: 'POST', + headers: headerParameters, + query: queryParameters, + body: formParams, + }, initOverrides); + + if (this.isJsonMime(response.headers.get('content-type'))) { + return new runtime.JSONApiResponse(response); + } else { + return new runtime.TextApiResponse(response) as any; + } + } + + /** + * Verify Email Update + */ + async verifyEmailUpdate(requestParameters: EmailUpdateApiVerifyEmailUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.verifyEmailUpdateRaw(requestParameters, initOverrides); + return await response.value(); + } + +} diff --git a/clients/packages/sdk/src/client/apis/index.ts b/clients/packages/sdk/src/client/apis/index.ts index 7370326bc0..db4b287e24 100644 --- a/clients/packages/sdk/src/client/apis/index.ts +++ b/clients/packages/sdk/src/client/apis/index.ts @@ -21,6 +21,7 @@ export * from './CustomerPortalSubscriptionsApi'; export * from './CustomersApi'; export * from './DashboardApi'; export * from './DiscountsApi'; +export * from './EmailUpdateApi'; export * from './EmbedsApi'; export * from './ExternalOrganizationsApi'; export * from './FilesApi'; diff --git a/clients/packages/sdk/src/client/models/index.ts b/clients/packages/sdk/src/client/models/index.ts index 506dc15188..20f7c27c97 100644 --- a/clients/packages/sdk/src/client/models/index.ts +++ b/clients/packages/sdk/src/client/models/index.ts @@ -4041,6 +4041,12 @@ export interface Checkout { * @memberof Checkout */ attached_custom_fields: Array; + /** + * + * @type {{ [key: string]: MetadataValue; }} + * @memberof Checkout + */ + customer_metadata: { [key: string]: MetadataValue; }; } @@ -4779,7 +4785,16 @@ export type CheckoutLinkSortProperty = typeof CheckoutLinkSortProperty[keyof typ */ export interface CheckoutLinkUpdate { /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean + * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof CheckoutLinkUpdate */ @@ -4905,6 +4920,21 @@ export interface CheckoutPriceCreate { * @memberof CheckoutPriceCreate */ customer_tax_id?: string | null; + /** + * Key-value object allowing you to store additional information that'll be copied to the created customer. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean + * + * You can store up to **50 key-value pairs**. + * @type {{ [key: string]: MetadataValue1; }} + * @memberof CheckoutPriceCreate + */ + customer_metadata?: { [key: string]: MetadataValue1; }; /** * * @type {string} @@ -5103,6 +5133,21 @@ export interface CheckoutProductCreate { * @memberof CheckoutProductCreate */ customer_tax_id?: string | null; + /** + * Key-value object allowing you to store additional information that'll be copied to the created customer. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean + * + * You can store up to **50 key-value pairs**. + * @type {{ [key: string]: MetadataValue1; }} + * @memberof CheckoutProductCreate + */ + customer_metadata?: { [key: string]: MetadataValue1; }; /** * * @type {string} @@ -5693,7 +5738,16 @@ export interface CheckoutUpdate { */ customer_tax_id?: string | null; /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof CheckoutUpdate */ @@ -5716,6 +5770,21 @@ export interface CheckoutUpdate { * @memberof CheckoutUpdate */ customer_ip_address?: string | null; + /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean + * + * You can store up to **50 key-value pairs**. + * @type {{ [key: string]: MetadataValue1; }} + * @memberof CheckoutUpdate + */ + customer_metadata?: { [key: string]: MetadataValue1; } | null; /** * * @type {string} @@ -6811,7 +6880,16 @@ export type CustomFieldUpdate = { type: 'checkbox' } & CustomFieldUpdateCheckbox */ export interface CustomFieldUpdateCheckbox { /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof CustomFieldUpdateCheckbox */ @@ -6858,7 +6936,16 @@ export type CustomFieldUpdateCheckboxTypeEnum = typeof CustomFieldUpdateCheckbox */ export interface CustomFieldUpdateDate { /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof CustomFieldUpdateDate */ @@ -6905,7 +6992,16 @@ export type CustomFieldUpdateDateTypeEnum = typeof CustomFieldUpdateDateTypeEnum */ export interface CustomFieldUpdateNumber { /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof CustomFieldUpdateNumber */ @@ -6952,7 +7048,16 @@ export type CustomFieldUpdateNumberTypeEnum = typeof CustomFieldUpdateNumberType */ export interface CustomFieldUpdateSelect { /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof CustomFieldUpdateSelect */ @@ -6999,7 +7104,16 @@ export type CustomFieldUpdateSelectTypeEnum = typeof CustomFieldUpdateSelectType */ export interface CustomFieldUpdateText { /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof CustomFieldUpdateText */ @@ -9989,7 +10103,16 @@ export type DiscountType = typeof DiscountType[keyof typeof DiscountType]; */ export interface DiscountUpdate { /** + * Key-value object allowing you to store additional information. + * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof DiscountUpdate */ @@ -10306,6 +10429,25 @@ export interface DownloadableRead { */ file: FileDownload; } +/** + * + * @export + * @interface EmailUpdateRequest + */ +export interface EmailUpdateRequest { + /** + * + * @type {string} + * @memberof EmailUpdateRequest + */ + email: string; + /** + * + * @type {string} + * @memberof EmailUpdateRequest + */ + return_to?: string | null; +} /** * * @export @@ -17536,7 +17678,16 @@ export interface ProductStorefront { */ export interface ProductUpdate { /** + * Key-value object allowing you to store additional information. * + * The key must be a string with a maximum length of **40 characters**. + * The value must be either: + * + * * A string with a maximum length of **500 characters** + * * An integer + * * A boolean + * + * You can store up to **50 key-value pairs**. * @type {{ [key: string]: MetadataValue1; }} * @memberof ProductUpdate */ diff --git a/server/migrations/versions/2024-12-18-1130_add_emailverification.py b/server/migrations/versions/2024-12-18-1130_add_emailverification.py new file mode 100644 index 0000000000..49ad193bb9 --- /dev/null +++ b/server/migrations/versions/2024-12-18-1130_add_emailverification.py @@ -0,0 +1,83 @@ +"""Add EmailVerification + +Revision ID: eaf307b21bd9 +Revises: cb9906114207 +Create Date: 2024-12-16 11:30:02.693730 + +""" + +import sqlalchemy as sa +from alembic import op + +# Polar Custom Imports + +# revision identifiers, used by Alembic. +revision = "eaf307b21bd9" +down_revision = "cb9906114207" +branch_labels: tuple[str] | None = None +depends_on: tuple[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "email_verification", + sa.Column("email", sa.String(), nullable=False), + sa.Column("token_hash", sa.String(), nullable=False), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("modified_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("email_verification_user_id_fkey"), + ondelete="cascade", + ), + sa.PrimaryKeyConstraint("id", name=op.f("email_verification_pkey")), + ) + op.create_index( + op.f("ix_email_verification_created_at"), + "email_verification", + ["created_at"], + unique=False, + ) + op.create_index( + op.f("ix_email_verification_deleted_at"), + "email_verification", + ["deleted_at"], + unique=False, + ) + op.create_index( + op.f("ix_email_verification_modified_at"), + "email_verification", + ["modified_at"], + unique=False, + ) + op.create_index( + op.f("ix_email_verification_token_hash"), + "email_verification", + ["token_hash"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_email_verification_token_hash"), table_name="email_verification" + ) + op.drop_index( + op.f("ix_email_verification_modified_at"), table_name="email_verification" + ) + op.drop_index( + op.f("ix_email_verification_deleted_at"), table_name="email_verification" + ) + op.drop_index( + op.f("ix_email_verification_created_at"), table_name="email_verification" + ) + op.drop_table("email_verification") + # ### end Alembic commands ### diff --git a/server/polar/api.py b/server/polar/api.py index 7790ab9d28..64ee290f98 100644 --- a/server/polar/api.py +++ b/server/polar/api.py @@ -13,6 +13,7 @@ from polar.customer_portal.endpoints import router as customer_portal_router from polar.dashboard.endpoints import router as dashboard_router from polar.discount.endpoints import router as discount_router +from polar.email_update.endpoints import router as email_update_router from polar.embed.endpoints import router as embed_router from polar.eventstream.endpoints import router as stream_router from polar.external_organization.endpoints import router as external_organization_router @@ -132,3 +133,5 @@ router.include_router(customer_router) # /customer-portal router.include_router(customer_portal_router) +# /update-email +router.include_router(email_update_router) diff --git a/server/polar/config.py b/server/polar/config.py index a3ee236818..cecc77f1e9 100644 --- a/server/polar/config.py +++ b/server/polar/config.py @@ -70,6 +70,9 @@ class Settings(BaseSettings): # Magic link MAGIC_LINK_TTL_SECONDS: int = 60 * 30 # 30 minutes + # Email verification + EMAIL_VERIFICATION_TTL_SECONDS: int = 60 * 30 # 30 minutes + # Checkout CHECKOUT_TTL_SECONDS: int = 60 * 60 # 1 hour IP_GEOLOCATION_DATABASE_DIRECTORY_PATH: DirectoryPath = Path(__file__).parent.parent diff --git a/server/polar/email_update/__init__.py b/server/polar/email_update/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/polar/email_update/email_templates/email_update.html b/server/polar/email_update/email_templates/email_update.html new file mode 100644 index 0000000000..e163f21bf4 --- /dev/null +++ b/server/polar/email_update/email_templates/email_update.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block body %} +

Hi,

+

Here is the verification link to update your email. Click the button below to complete the update process. This link + is only valid for the next {{ token_lifetime_minutes }} minutes.

+ + + + + + + + + + + + +{% endblock %} diff --git a/server/polar/email_update/endpoints.py b/server/polar/email_update/endpoints.py new file mode 100644 index 0000000000..d166b6a839 --- /dev/null +++ b/server/polar/email_update/endpoints.py @@ -0,0 +1,63 @@ +from fastapi import Depends, Form +from fastapi.responses import RedirectResponse + +from polar.auth.dependencies import WebUser +from polar.config import settings +from polar.exceptions import PolarRedirectionError +from polar.integrations.loops.service import loops as loops_service +from polar.kit.db.postgres import AsyncSession +from polar.kit.http import ReturnTo, get_safe_return_url +from polar.openapi import APITag +from polar.postgres import get_db_session +from polar.routing import APIRouter + +from .schemas import EmailUpdateRequest +from .service import EmailUpdateError +from .service import email_update as email_update_service + +router = APIRouter(prefix="/email-update", tags=["email-update", APITag.private]) + + +@router.post("/request") +async def request_email_update( + email_update_request: EmailUpdateRequest, + auth_subject: WebUser, + session: AsyncSession = Depends(get_db_session), +) -> None: + email_update_record, token = await email_update_service.request_email_update( + email_update_request.email, + session, + auth_subject, + ) + + await email_update_service.send_email( + email_update_record, + token, + base_url=str(settings.generate_frontend_url("/verify-email")), + extra_url_params=( + {"return_to": email_update_request.return_to} + if email_update_request.return_to + else {} + ), + ) + + +@router.post("/verify") +async def verify_email_update( + return_to: ReturnTo, + token: str = Form(), + session: AsyncSession = Depends(get_db_session), +) -> RedirectResponse: + try: + user = await email_update_service.verify(session, token) + except EmailUpdateError as e: + raise PolarRedirectionError( + e.message, e.status_code, return_to=return_to + ) from e + + await loops_service.user_update(session, user) + + return_url = get_safe_return_url(return_to) + response = RedirectResponse(return_url, 303) + + return response diff --git a/server/polar/email_update/schemas.py b/server/polar/email_update/schemas.py new file mode 100644 index 0000000000..6cd79a1e93 --- /dev/null +++ b/server/polar/email_update/schemas.py @@ -0,0 +1,14 @@ +from pydantic import field_validator + +from polar.kit.http import get_safe_return_url +from polar.kit.schemas import EmailStrDNS, Schema + + +class EmailUpdateRequest(Schema): + email: EmailStrDNS + return_to: str | None = None + + @field_validator("return_to") + @classmethod + def validate_return_to(cls, v: str | None) -> str: + return get_safe_return_url(v) diff --git a/server/polar/email_update/service.py b/server/polar/email_update/service.py new file mode 100644 index 0000000000..b71b8fc2f3 --- /dev/null +++ b/server/polar/email_update/service.py @@ -0,0 +1,137 @@ +import datetime +from math import ceil +from urllib.parse import urlencode + +from sqlalchemy import delete +from sqlalchemy.orm import joinedload + +from polar.auth.models import AuthSubject +from polar.config import settings +from polar.email.renderer import get_email_renderer +from polar.email.sender import get_email_sender +from polar.exceptions import PolarError, PolarRequestValidationError +from polar.kit.crypto import generate_token_hash_pair, get_token_hash +from polar.kit.extensions.sqlalchemy import sql +from polar.kit.services import ResourceServiceReader +from polar.kit.utils import utc_now +from polar.models import EmailVerification +from polar.models.user import User +from polar.postgres import AsyncSession +from polar.user.service.user import user as user_service + +TOKEN_PREFIX = "polar_ev_" + + +class EmailUpdateError(PolarError): ... + + +class InvalidEmailUpdate(EmailUpdateError): + def __init__(self) -> None: + super().__init__( + "This email update request is invalid or has expired.", status_code=401 + ) + + +class EmailUpdateService(ResourceServiceReader[EmailVerification]): + async def request_email_update( + self, + email: str, + session: AsyncSession, + auth_subject: AuthSubject[User], + ) -> tuple[EmailVerification, str]: + user = auth_subject.subject + + existing_user = await user_service.get_by_email(session, email) + if existing_user is not None and existing_user.id != user.id: + raise PolarRequestValidationError( + [ + { + "type": "value_error", + "loc": ("body", "email"), + "msg": "Another user is already using this email.", + "input": email, + } + ] + ) + + token, token_hash = generate_token_hash_pair( + secret=settings.SECRET, prefix=TOKEN_PREFIX + ) + email_update_record = EmailVerification( + email=email, token_hash=token_hash, user=user + ) + + session.add(email_update_record) + await session.flush() + + return email_update_record, token + + async def send_email( + self, + email_update_record: EmailVerification, + token: str, + base_url: str, + *, + extra_url_params: dict[str, str] = {}, + ) -> None: + email_renderer = get_email_renderer({"email_update": "polar.email_update"}) + email_sender = get_email_sender() + + delta = email_update_record.expires_at - utc_now() + token_lifetime_minutes = int(ceil(delta.seconds / 60)) + + url_params = {"token": token, **extra_url_params} + subject, body = email_renderer.render_from_template( + "Update your email", + "email_update/email_update.html", + { + "token_lifetime_minutes": token_lifetime_minutes, + "url": f"{base_url}?{urlencode(url_params)}", + "current_year": datetime.datetime.now().year, + }, + ) + + email_sender.send_to_user( + to_email_addr=email_update_record.email, subject=subject, html_content=body + ) + + async def verify(self, session: AsyncSession, token: str) -> User: + token_hash = get_token_hash(token, secret=settings.SECRET) + email_update_record = await self._get_email_update_record_by_token_hash( + session, token_hash + ) + + if email_update_record is None: + raise InvalidEmailUpdate() + + user = email_update_record.user + user.email = email_update_record.email + + await session.delete(email_update_record) + + return user + + async def _get_email_update_record_by_token_hash( + self, session: AsyncSession, token_hash: str + ) -> EmailVerification | None: + statement = ( + sql.select(EmailVerification) + .where( + EmailVerification.token_hash == token_hash, + EmailVerification.expires_at > utc_now(), + ) + .options(joinedload(EmailVerification.user)) + ) + + res = await session.execute(statement) + return res.scalars().unique().one_or_none() + + async def delete_expired_record(self, session: AsyncSession) -> None: + statement = delete(EmailVerification).where( + EmailVerification.expires_at < utc_now() + ) + await session.execute(statement) + await session.flush() + + +email_update = EmailUpdateService(EmailVerification) diff --git a/server/polar/email_update/tasks.py b/server/polar/email_update/tasks.py new file mode 100644 index 0000000000..56983fce75 --- /dev/null +++ b/server/polar/email_update/tasks.py @@ -0,0 +1,15 @@ +from logging import Logger + +import structlog + +from polar.worker import AsyncSessionMaker, CronTrigger, JobContext, task + +from .service import email_update as email_update_service + +log: Logger = structlog.get_logger() + + +@task("email_update.delete_expired_record", cron_trigger=CronTrigger(hour=0, minute=0)) +async def email_update_delete_expired_record(ctx: JobContext) -> None: + async with AsyncSessionMaker(ctx) as session: + await email_update_service.delete_expired_record(session) diff --git a/server/polar/models/__init__.py b/server/polar/models/__init__.py index f976ddd046..4191b6a2b0 100644 --- a/server/polar/models/__init__.py +++ b/server/polar/models/__init__.py @@ -14,6 +14,7 @@ from .discount_product import DiscountProduct from .discount_redemption import DiscountRedemption from .downloadable import Downloadable +from .email_verification import EmailVerification from .external_organization import ExternalOrganization from .file import File from .held_balance import HeldBalance @@ -71,6 +72,7 @@ "DiscountProduct", "DiscountRedemption", "Downloadable", + "EmailVerification", "ExternalOrganization", "File", "HeldBalance", diff --git a/server/polar/models/email_verification.py b/server/polar/models/email_verification.py new file mode 100644 index 0000000000..cdd61e81c8 --- /dev/null +++ b/server/polar/models/email_verification.py @@ -0,0 +1,31 @@ +from datetime import datetime, timedelta +from uuid import UUID + +from sqlalchemy import TIMESTAMP, ForeignKey, String, Uuid +from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship + +from polar.config import settings +from polar.kit.db.models import RecordModel +from polar.kit.utils import utc_now +from polar.models.user import User + + +def get_expires_at() -> datetime: + return utc_now() + timedelta(seconds=settings.EMAIL_VERIFICATION_TTL_SECONDS) + + +class EmailVerification(RecordModel): + __tablename__ = "email_verification" + + email: Mapped[str] = mapped_column(String, nullable=False) + token_hash: Mapped[str] = mapped_column(String, index=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), nullable=False, default=get_expires_at + ) + user_id: Mapped[UUID] = mapped_column( + Uuid, ForeignKey("users.id", ondelete="cascade"), nullable=False + ) + + @declared_attr + def user(cls) -> Mapped[User]: + return relationship(User) diff --git a/server/polar/tasks.py b/server/polar/tasks.py index bb1ca7db3b..06fa61360c 100644 --- a/server/polar/tasks.py +++ b/server/polar/tasks.py @@ -3,6 +3,7 @@ from polar.benefit import tasks as benefit from polar.checkout import tasks as checkout from polar.customer_session import tasks as customer_session +from polar.email_update import tasks as email_update from polar.eventstream import tasks as eventstream from polar.integrations.github import tasks as github from polar.integrations.loops import tasks as loops @@ -23,6 +24,7 @@ "benefit", "checkout", "customer_session", + "email_update", "eventstream", "github", "loops",