mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: add email change form to settings page
This commit is contained in:
@@ -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"
|
||||
|
||||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -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 }} \
|
||||
|
||||
@@ -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
|
||||
|
||||
2
env.mjs
2
env.mjs
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>`,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user