feat: add email change form to settings page

This commit is contained in:
Artur
2024-10-02 16:06:03 -03:00
parent 339fa047a7
commit 9b782c3f6b
10 changed files with 250 additions and 101 deletions

View File

@@ -4,6 +4,7 @@ DATABASE_URL="postgresql://magic:magic@magic-postgres:5432/magic?schema=public"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_URL_INTERNAL="http://localhost:3000"
NEXTAUTH_SECRET=""
USER_SETTINGS_JWT_SECRET=""
SMTP_HOST="sandbox.smtp.mailtrap.io"
SMTP_PORT="2525"

View File

@@ -29,6 +29,7 @@ jobs:
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \
DATABASE_URL=${{ secrets.DATABASE_URL }} \
NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }} \
USER_SETTINGS_JWT_SECRET=${{ secrets.USER_SETTINGS_JWT_SECRET }} \
SMTP_USER=${{ secrets.SMTP_USER }} \
SMTP_PASS=${{ secrets.SMTP_PASS }} \
STRIPE_MONERO_SECRET_KEY=${{ secrets.STRIPE_MONERO_SECRET_KEY }} \

View File

@@ -32,6 +32,7 @@ services:
NEXTAUTH_URL: https://donate.magicgrants.org
NEXTAUTH_URL_INTERNAL: http://localhost:3000
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
USER_SETTINGS_JWT_SECRET: ${USER_SETTINGS_JWT_SECRET}
SMTP_HOST: email-smtp.us-east-2.amazonaws.com
SMTP_PORT: 587

View File

@@ -11,6 +11,7 @@ export const env = createEnv({
BUILD_MODE: z.boolean(),
APP_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
USER_SETTINGS_JWT_SECRET: z.string().min(32),
SMTP_HOST: z.string().min(1),
SMTP_PORT: z.string().min(1),
@@ -63,6 +64,7 @@ export const env = createEnv({
BUILD_MODE: !!process.env.BUILD_MODE,
APP_URL: process.env.APP_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
USER_SETTINGS_JWT_SECRET: process.env.USER_SETTINGS_JWT_SECRET,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,

View File

@@ -16,6 +16,8 @@ import { Button } from '../../../components/ui/button'
import Spinner from '../../../components/Spinner'
import { toast } from '../../../components/ui/use-toast'
import { trpc } from '../../../utils/trpc'
import { useFundSlug } from '../../../utils/use-fund-slug'
import { useSession } from 'next-auth/react'
const changePasswordFormSchema = z
.object({
@@ -28,10 +30,16 @@ const changePasswordFormSchema = z
path: ['confirmNewPassword'],
})
const changeEmailFormSchema = z.object({ newEmail: z.string().email() })
type ChangePasswordFormInputs = z.infer<typeof changePasswordFormSchema>
type ChangeEmailFormInputs = z.infer<typeof changeEmailFormSchema>
function Settings() {
const changePassword = trpc.account.changePassword.useMutation()
const fundSlug = useFundSlug()
const session = useSession()
const changePasswordMutation = trpc.account.changePassword.useMutation()
const requestEmailChangeMutation = trpc.account.requestEmailChange.useMutation()
const changePasswordForm = useForm<ChangePasswordFormInputs>({
resolver: zodResolver(changePasswordFormSchema),
@@ -43,9 +51,15 @@ function Settings() {
mode: 'all',
})
const changeEmailForm = useForm<ChangeEmailFormInputs>({
resolver: zodResolver(changeEmailFormSchema),
defaultValues: { newEmail: '' },
mode: 'all',
})
async function onChangePasswordSubmit(data: ChangePasswordFormInputs) {
try {
await changePassword.mutateAsync({
await changePasswordMutation.mutateAsync({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
})
@@ -71,71 +85,131 @@ function Settings() {
}
}
async function onChangeEmailSubmit(data: ChangeEmailFormInputs) {
if (!fundSlug) return
try {
await requestEmailChangeMutation.mutateAsync({ fundSlug, newEmail: data.newEmail })
changeEmailForm.reset()
toast({ title: 'A verification link has been sent to your email.' })
} catch (error) {
const errorMessage = (error as any).message
if (errorMessage === 'EMAIL_TAKEN') {
return changeEmailForm.setError(
'newEmail',
{ message: 'Email is already taken.' },
{ shouldFocus: true }
)
}
return toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
}
return (
<>
<Head>
<title>MAGIC Grants - Settings</title>
</Head>
<div className="w-full max-w-xl mx-auto flex flex-col">
<h1 className="font-semibold">Change Password</h1>
<div className="w-full max-w-lg mx-auto flex flex-col space-y-12">
<div className="w-full flex flex-col space-y-6">
<h1 className="font-semibold">Change Password</h1>
<Form {...changePasswordForm}>
<form
onSubmit={changePasswordForm.handleSubmit(onChangePasswordSubmit)}
className="flex flex-col gap-4"
>
<FormField
control={changePasswordForm.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changePasswordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changePasswordForm.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm new password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
disabled={
changePasswordForm.formState.isSubmitting || !changePasswordForm.formState.isValid
}
<Form {...changePasswordForm}>
<form
onSubmit={changePasswordForm.handleSubmit(onChangePasswordSubmit)}
className="flex flex-col space-y-4"
>
{changePasswordForm.formState.isSubmitting && <Spinner />} Change Password
</Button>
</form>
</Form>
<FormField
control={changePasswordForm.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changePasswordForm.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>New password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={changePasswordForm.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm new password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
disabled={
changePasswordForm.formState.isSubmitting || !changePasswordForm.formState.isValid
}
>
{changePasswordForm.formState.isSubmitting && <Spinner />} Change Password
</Button>
</form>
</Form>
</div>
<div className="w-full flex flex-col space-y-6">
<h1 className="font-semibold">Change Email</h1>
<Form {...changeEmailForm}>
<form
onSubmit={changeEmailForm.handleSubmit(onChangeEmailSubmit)}
className="flex flex-col space-y-4"
>
<FormField
control={changeEmailForm.control}
name="newEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder={session.data?.user.email} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
disabled={
changeEmailForm.formState.isSubmitting || !changeEmailForm.formState.isValid
}
>
{changeEmailForm.formState.isSubmitting && <Spinner />} Change Email
</Button>
</form>
</Form>
</div>
</div>
</>
)

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'next/router'
import { trpc } from '../../../utils/trpc'
import { useToast } from '../../../components/ui/use-toast'
import { useFundSlug } from '../../../utils/use-fund-slug'
import { signOut } from 'next-auth/react'
function VerifyEmail() {
const router = useRouter()
@@ -18,12 +19,9 @@ function VerifyEmail() {
if (!token) return
try {
const result = await verifyEmailMutation.mutateAsync({
token: token as string,
})
router.push(`/${fundSlug}/?loginEmail=${result.email}`)
const result = await verifyEmailMutation.mutateAsync({ token: token as string })
toast({ title: 'Email verified! You may now log in.' })
await signOut({ callbackUrl: `/${fundSlug}/?loginEmail=${result.email}` })
} catch (error) {
toast({ title: 'Invalid verification link.', variant: 'destructive' })
router.push(`/${fundSlug}`)

View File

@@ -2,12 +2,14 @@ import { z } from 'zod'
import { jwtDecode } from 'jwt-decode'
import { TRPCError } from '@trpc/server'
import axios from 'axios'
import jwt from 'jsonwebtoken'
import { protectedProcedure, router } from '../trpc'
import { env } from '../../env.mjs'
import { KeycloakJwtPayload } from '../types'
import { keycloak } from '../services'
import { KeycloakJwtPayload, UserSettingsJwtPayload } from '../types'
import { keycloak, transporter } from '../services'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { fundSlugs } from '../../utils/funds'
export const accountRouter = router({
changePassword: protectedProcedure
@@ -57,4 +59,53 @@ export const accountRouter = router({
}
)
}),
requestEmailChange: protectedProcedure
.input(z.object({ fundSlug: z.enum(fundSlugs), newEmail: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const userId = ctx.session.user.sub
const email = ctx.session.user.email
await authenticateKeycloakClient()
const usersAlreadyUsingEmail = await keycloak.users.find({ email: input.newEmail })
if (usersAlreadyUsingEmail.length) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'EMAIL_TAKEN' })
}
const user = await keycloak.users.findOne({ id: userId })
if (!user || !user.id || !user.email || !user.attributes)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
let emailVerifyTokenVersion = parseInt(user.attributes.emailVerifyTokenVersion?.[0]) || null
if (!emailVerifyTokenVersion) {
await keycloak.users.update(
{ id: userId },
{ email: user.email, attributes: { emailVerifyTokenVersion: 1 } }
)
emailVerifyTokenVersion = 1
}
const payload: UserSettingsJwtPayload = {
action: 'email_verify',
userId: user.id,
email: input.newEmail,
tokenVersion: emailVerifyTokenVersion,
}
const token = jwt.sign(payload, env.USER_SETTINGS_JWT_SECRET, { expiresIn: '30m' })
// no await here as we don't want to block the response
transporter.sendMail({
from: env.SES_VERIFIED_SENDER,
to: input.newEmail,
subject: 'Verify your email',
html: `<a href="${env.APP_URL}/${input.fundSlug}/verify-email/${token}" target="_blank">Verify email</a>`,
})
}),
})

View File

@@ -2,24 +2,12 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import jwt from 'jsonwebtoken'
import { publicProcedure, router } from '../trpc'
import { protectedProcedure, publicProcedure, router } from '../trpc'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { keycloak, transporter } from '../services'
import { env } from '../../env.mjs'
import { fundSlugs } from '../../utils/funds'
type EmailVerifyJwtPayload = {
action: 'email_verify'
userId: string
email: string
}
type PasswordResetJwtPayload = {
action: 'password-reset'
userId: string
email: string
tokenVersion: number
}
import { UserSettingsJwtPayload } from '../types'
export const authRouter = router({
register: publicProcedure
@@ -42,7 +30,11 @@ export const authRouter = router({
email: input.email,
credentials: [{ type: 'password', value: input.password, temporary: false }],
requiredActions: ['VERIFY_EMAIL'],
attributes: { name: input.name, passwordResetTokenVersion: 1 },
attributes: {
name: input.name,
passwordResetTokenVersion: 1,
emailVerifyTokenVersion: 1,
},
enabled: true,
})
} catch (error) {
@@ -53,15 +45,14 @@ export const authRouter = router({
throw error
}
const payload: EmailVerifyJwtPayload = {
const payload: UserSettingsJwtPayload = {
action: 'email_verify',
tokenVersion: 1,
userId: user.id,
email: input.email,
}
const emailVerifyToken = jwt.sign(payload, env.NEXTAUTH_SECRET, {
expiresIn: '1d',
})
const emailVerifyToken = jwt.sign(payload, env.USER_SETTINGS_JWT_SECRET, { expiresIn: '1d' })
// no await here as we don't want to block the response
transporter.sendMail({
@@ -75,10 +66,10 @@ export const authRouter = router({
verifyEmail: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ input }) => {
let decoded: EmailVerifyJwtPayload
let decoded: UserSettingsJwtPayload
try {
decoded = jwt.verify(input.token, env.NEXTAUTH_SECRET) as EmailVerifyJwtPayload
decoded = jwt.verify(input.token, env.USER_SETTINGS_JWT_SECRET) as UserSettingsJwtPayload
} catch (error) {
throw new TRPCError({
code: 'FORBIDDEN',
@@ -95,9 +86,33 @@ export const authRouter = router({
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: decoded.userId })
if (!user || !user.id || !user.attributes)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const emailVerifyTokenVersion = parseInt(user.attributes.emailVerifyTokenVersion?.[0]) || null
if (emailVerifyTokenVersion !== decoded.tokenVersion) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'INVALID_TOKEN_VERSION',
})
}
await keycloak.users.update(
{ id: decoded.userId },
{ emailVerified: true, requiredActions: [] }
{
email: decoded.email,
emailVerified: true,
requiredActions: [],
attributes: {
emailVerifyTokenVersion: (emailVerifyTokenVersion + 1).toString(),
},
}
)
return { email: decoded.email }
@@ -116,25 +131,26 @@ export const authRouter = router({
message: 'USER_NOT_FOUND',
})
const passwordResetTokenVersion =
let passwordResetTokenVersion =
parseInt(user.attributes.passwordResetTokenVersion?.[0]) || null
if (!passwordResetTokenVersion) {
console.error(`User ${user.id} has no passwordResetTokenVersion attribute`)
await keycloak.users.update(
{ id: user.id },
{ email: input.email, attributes: { passwordResetTokenVersion: 1 } }
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
})
passwordResetTokenVersion = 1
}
const payload: PasswordResetJwtPayload = {
const payload: UserSettingsJwtPayload = {
action: 'password-reset',
userId: user.id,
email: input.email,
tokenVersion: passwordResetTokenVersion,
}
const passwordResetToken = jwt.sign(payload, env.NEXTAUTH_SECRET, {
const passwordResetToken = jwt.sign(payload, env.USER_SETTINGS_JWT_SECRET, {
expiresIn: '30m',
})
@@ -150,10 +166,10 @@ export const authRouter = router({
resetPassword: publicProcedure
.input(z.object({ token: z.string(), password: z.string().min(8) }))
.mutation(async ({ input }) => {
let decoded: PasswordResetJwtPayload
let decoded: UserSettingsJwtPayload
try {
decoded = jwt.verify(input.token, env.NEXTAUTH_SECRET) as PasswordResetJwtPayload
decoded = jwt.verify(input.token, env.USER_SETTINGS_JWT_SECRET) as UserSettingsJwtPayload
} catch (error) {
throw new TRPCError({
code: 'FORBIDDEN',

View File

@@ -177,6 +177,7 @@ export const donationRouter = router({
const user = await keycloak.users.findOne({ id: userId })
const email = user?.email!
const name = user?.attributes?.name?.[0]!
let stripeCustomerId =
user?.attributes?.[fundSlugToCustomerIdAttr[input.fundSlug]]?.[0] || null
@@ -185,10 +186,7 @@ export const donationRouter = router({
stripeCustomerId = customer.id
await keycloak.users.update(
{ id: userId },
{ email: email, attributes: { stripeCustomerId } }
)
await keycloak.users.update({ id: userId }, { email, attributes: { stripeCustomerId } })
}
const metadata: DonationMetadata = {

View File

@@ -5,6 +5,13 @@ export type KeycloakJwtPayload = {
email: string
}
export type UserSettingsJwtPayload = {
action: 'email_verify' | 'password-reset'
tokenVersion: number
userId: string
email: string
}
export type DonationMetadata = {
userId: string | null
donorEmail: string | null