Password reset without keycloak UI

This commit is contained in:
Artur N
2024-06-13 16:52:14 -03:00
parent d4d4eb716b
commit a2f34b86e3
10 changed files with 419 additions and 108 deletions

View File

@@ -7,7 +7,7 @@ import MobileNav from './MobileNav'
import ThemeSwitch from './ThemeSwitch'
import headerNavLinks from '../data/headerNavLinks'
import Logo from './Logo'
import { Dialog, DialogTrigger } from './ui/dialog'
import { Dialog, DialogContent, DialogTrigger } from './ui/dialog'
import { Button } from './ui/button'
import RegisterFormModal from './RegisterFormModal'
import LoginFormModal from './LoginFormModal'
@@ -62,17 +62,21 @@ const Header = () => {
Login
</Button>
</DialogTrigger>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
<DialogContent>
<LoginFormModal
close={() => setLoginIsOpen(false)}
openPasswordResetModal={() => setPasswordResetIsOpen(true)}
/>
</DialogContent>
</Dialog>
<Dialog open={registerIsOpen} onOpenChange={setRegisterIsOpen}>
<DialogTrigger asChild>
<Button className="w-24">Register</Button>
</DialogTrigger>
<RegisterFormModal close={() => setRegisterIsOpen(false)} />
<DialogContent>
<RegisterFormModal close={() => setRegisterIsOpen(false)} />
</DialogContent>
</Dialog>
</>
)}
@@ -89,7 +93,9 @@ const Header = () => {
</div>
<Dialog open={passwordResetIsOpen} onOpenChange={setPasswordResetIsOpen}>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
<DialogContent>
<PasswordResetFormModal close={() => setPasswordResetIsOpen(false)} />
</DialogContent>
</Dialog>
</header>
)

View File

@@ -20,7 +20,7 @@ const LayoutWrapper = ({ children }: Props) => {
className={`${inter.className} flex h-screen flex-col justify-between font-sans`}
>
<Header />
<main className="mb-auto">{children}</main>
<main>{children}</main>
<Footer />
</div>
</SectionContainer>

View File

@@ -79,7 +79,7 @@ function LoginFormModal({ close, openPasswordResetModal }: Props) {
}
return (
<DialogContent>
<>
<DialogHeader>
<DialogTitle>Login</DialogTitle>
<DialogDescription>Log into your account.</DialogDescription>
@@ -137,7 +137,7 @@ function LoginFormModal({ close, openPasswordResetModal }: Props) {
</Button>
</form>
</Form>
</DialogContent>
</>
)
}

View File

@@ -53,7 +53,7 @@ function PasswordResetFormModal({ close }: Props) {
}
return (
<DialogContent>
<>
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
<DialogDescription>Recover your account.</DialogDescription>
@@ -86,7 +86,7 @@ function PasswordResetFormModal({ close }: Props) {
</Button>
</form>
</Form>
</DialogContent>
</>
)
}

View File

@@ -22,10 +22,8 @@ import {
FormMessage,
} from './ui/form'
import { Button } from './ui/button'
import { trpc } from '../utils/trpc'
import { useToast } from './ui/use-toast'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { trpc } from '../utils/trpc'
const schema = z
.object({
@@ -56,7 +54,6 @@ function RegisterFormModal({ close }: Props) {
})
close()
form.reset({ email: '', password: '', confirmPassword: '' })
} catch (error) {
const errorMessage = (error as any).message
@@ -76,7 +73,7 @@ function RegisterFormModal({ close }: Props) {
}
return (
<DialogContent>
<>
<DialogHeader>
<DialogTitle>Register</DialogTitle>
<DialogDescription>
@@ -139,7 +136,7 @@ function RegisterFormModal({ close }: Props) {
</Button>
</form>
</Form>
</DialogContent>
</>
)
}

View File

@@ -1,42 +1,46 @@
import { faGithub, faTwitter } from "@fortawesome/free-brands-svg-icons"
import { faLink } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import Link from "next/link"
import escapeHTML from "escape-html"
import { ProjectItem } from "../utils/types"
import { faGithub, faTwitter } from '@fortawesome/free-brands-svg-icons'
import { faLink } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import Link from 'next/link'
import escapeHTML from 'escape-html'
import { ProjectItem } from '../utils/types'
const ShareButtons: React.FC<{ project: ProjectItem }> = ({ project }) => {
const { git, twitter, website } = project;
return (
<div className="flex space-x-4">
<Link href={escapeHTML(git)} passHref legacyBehavior>
<a className="projectlist">
<FontAwesomeIcon
icon={faGithub}
className="w-[2rem] h-[2rem] hover:text-primary cursor-pointer"
/>
</a>
</Link>
<Link href={`https://twitter.com/${escapeHTML(twitter)}`} passHref legacyBehavior>
<a className="projectlist">
<FontAwesomeIcon
icon={faTwitter}
className="w-[2rem] h-[2rem] hover:text-primary cursor-pointer"
/>
</a>
</Link>
{website && <Link href={escapeHTML(website)} passHref legacyBehavior>
<a className="projectlist">
<FontAwesomeIcon
icon={faLink}
className="w-[2rem] h-[2rem] hover:text-primary cursor-pointer"
/>
</a>
</Link>}
</div>
)
const { git, twitter, website } = project
return (
<div className="flex space-x-4">
<Link href={escapeHTML(git)} passHref legacyBehavior>
<a className="projectlist">
<FontAwesomeIcon
icon={faGithub}
className="w-[2rem] h-[2rem] hover:text-primary cursor-pointer"
/>
</a>
</Link>
<Link
href={`https://twitter.com/${escapeHTML(twitter)}`}
passHref
legacyBehavior
>
<a className="projectlist">
<FontAwesomeIcon
icon={faTwitter}
className="w-[2rem] h-[2rem] hover:text-primary cursor-pointer"
/>
</a>
</Link>
{website && (
<Link href={escapeHTML(website)} passHref legacyBehavior>
<a className="projectlist">
<FontAwesomeIcon
icon={faLink}
className="w-[2rem] h-[2rem] hover:text-primary cursor-pointer"
/>
</a>
</Link>
)}
</div>
)
}
export default ShareButtons
export default ShareButtons

View File

@@ -0,0 +1,163 @@
import { useForm } from 'react-hook-form'
import { ReloadIcon } from '@radix-ui/react-icons'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter } from 'next/router'
import { jwtDecode } from 'jwt-decode'
import { z } from 'zod'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../../components/ui/form'
import { Input } from '../../components/ui/input'
import { Button } from '../../components/ui/button'
import { toast } from '../../components/ui/use-toast'
import { useEffect } from 'react'
import { trpc } from '../../utils/trpc'
import { cn } from '../../utils/cn'
const schema = z
.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string().min(8),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
})
type ResetPasswordFormInputs = z.infer<typeof schema>
function ResetPassword() {
const router = useRouter()
const form = useForm<ResetPasswordFormInputs>({
resolver: zodResolver(schema),
})
const resetPasswordMutation = trpc.auth.resetPassword.useMutation()
async function onSubmit(data: ResetPasswordFormInputs) {
const { token } = router.query
if (!token) return
try {
await resetPasswordMutation.mutateAsync({
token: token as string,
password: data.password,
})
toast({ title: 'Password successfully reset. You may now log in.' })
router.push(`/?loginEmail=${data.email}`)
} catch (error) {
const errorMessage = (error as any).message
if (errorMessage === 'INVALID_TOKEN') {
toast({
title: 'Invalid password reset link.',
variant: 'destructive',
})
router.push('/')
return
}
toast({
title: 'Sorry, something went wrong.',
variant: 'destructive',
})
}
}
useEffect(() => {
const { token } = router.query
if (token) {
const decoded = jwtDecode(token as string) as { email: string }
if (decoded.email) {
form.setValue('email', decoded.email)
}
}
}, [router.query.token])
return (
<div className="w-full max-w-md m-auto">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col space-y-4"
>
<div className="flex flex-col space-y-1.5 text-center sm:text-left">
<span className="text-lg font-semibold leading-none tracking-tight">
Password Reset
</span>
<span className="text-sm text-muted-foreground">
Reset your password
</span>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} disabled />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}{' '}
Reset Password
</Button>
</form>
</Form>
</div>
)
}
export default ResetPassword

View File

@@ -13,7 +13,6 @@ function VerifyEmail() {
useEffect(() => {
;(async () => {
console.log(token)
if (!token) return
try {

View File

@@ -4,40 +4,41 @@ import jwt from 'jsonwebtoken'
import { publicProcedure, router } from '../trpc'
import { authenticateKeycloakClient } from '../utils/keycloak'
import { keycloak, sendgrid, transporter } from '../services'
import { keycloak, transporter } from '../services'
import { env } from '../../env.mjs'
type EmailVerifyJwtPayload = {
action: 'email_verify'
userId: string
email: string
}
type PasswordResetJwtPayload = {
action: 'password-reset'
userId: string
email: string
tokenVersion: number
}
export const authRouter = router({
register: publicProcedure
.input(z.object({ email: z.string().email(), password: z.string() }))
.mutation(async ({ input }) => {
await authenticateKeycloakClient()
let user: { id: string }
try {
const user = await keycloak.users.create({
user = await keycloak.users.create({
realm: 'monerofund',
email: input.email,
credentials: [
{ type: 'password', value: input.password, temporary: false },
],
requiredActions: ['VERIFY_EMAIL'],
attributes: { passwordResetTokenVersion: 1 },
enabled: true,
})
// Send verification email with Sendgrid
const token = jwt.sign(
{ userId: user.id, email: input.email },
env.NEXTAUTH_SECRET,
{ expiresIn: '1d' }
)
// no await here as we don't want to block the response
transporter.sendMail({
from: env.SENDGRID_VERIFIED_SENDER,
to: input.email,
subject: 'Verify your email',
html: `<a href="${env.APP_URL}/verify-email/${token}" target="_blank">Verify email</a>`,
})
} catch (error) {
if (
(error as any).responseData.errorMessage ===
@@ -46,60 +47,175 @@ export const authRouter = router({
throw new TRPCError({ code: 'BAD_REQUEST', message: 'EMAIL_TAKEN' })
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'UNKNOWN_ERROR',
})
throw error
}
const payload: EmailVerifyJwtPayload = {
action: 'email_verify',
userId: user.id,
email: input.email,
}
const emailVerifyToken = jwt.sign(payload, env.NEXTAUTH_SECRET, {
expiresIn: '1d',
})
// no await here as we don't want to block the response
transporter.sendMail({
from: env.SENDGRID_VERIFIED_SENDER,
to: input.email,
subject: 'Verify your email',
html: `<a href="${env.APP_URL}/verify-email/${emailVerifyToken}" target="_blank">Verify email</a>`,
})
}),
verifyEmail: publicProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ input }) => {
let decoded: EmailVerifyJwtPayload
try {
const decoded = jwt.verify(input.token, env.NEXTAUTH_SECRET) as {
userId: string
email: string
}
await authenticateKeycloakClient()
await keycloak.users.update(
{ id: decoded.userId },
{ emailVerified: true, requiredActions: [] }
)
return { email: decoded.email }
decoded = jwt.verify(
input.token,
env.NEXTAUTH_SECRET
) as EmailVerifyJwtPayload
} catch (error) {
console.error(error)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'UNKNOWN_ERROR',
code: 'FORBIDDEN',
message: 'INVALID_TOKEN',
})
}
if (decoded.action !== 'email_verify') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_ACTION',
})
}
await authenticateKeycloakClient()
await keycloak.users.update(
{ id: decoded.userId },
{ emailVerified: true, requiredActions: [] }
)
return { email: decoded.email }
}),
requestPasswordReset: publicProcedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => {
try {
await authenticateKeycloakClient()
await authenticateKeycloakClient()
const users = await keycloak.users.find({ email: input.email })
const user = users[0]
const users = await keycloak.users.find({ email: input.email })
const userId = users[0]?.id
if (!userId) return
await keycloak.users.executeActionsEmail({
id: userId,
actions: ['UPDATE_PASSWORD'],
if (!user || !user.id || !user.attributes)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
} catch (error) {
console.error(error)
const passwordResetTokenVersion =
parseInt(user.attributes.passwordResetTokenVersion?.[0]) || null
if (!passwordResetTokenVersion) {
console.error(
`User ${user.id} has no passwordResetTokenVersion attribute`
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'UNKNOWN_ERROR',
})
}
const payload: PasswordResetJwtPayload = {
action: 'password-reset',
userId: user.id,
email: input.email,
tokenVersion: passwordResetTokenVersion,
}
const passwordResetToken = jwt.sign(payload, env.NEXTAUTH_SECRET, {
expiresIn: '30m',
})
// no await here as we don't want to block the response
transporter.sendMail({
from: env.SENDGRID_VERIFIED_SENDER,
to: input.email,
subject: 'Reset your password',
html: `<a href="${env.APP_URL}/reset-password/${passwordResetToken}" target="_blank">Reset password</a>`,
})
}),
resetPassword: publicProcedure
.input(z.object({ token: z.string(), password: z.string().min(8) }))
.mutation(async ({ input }) => {
let decoded: PasswordResetJwtPayload
try {
decoded = jwt.verify(
input.token,
env.NEXTAUTH_SECRET
) as PasswordResetJwtPayload
} catch (error) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_TOKEN',
})
}
if (decoded.action !== 'password-reset') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_ACTION',
})
}
await authenticateKeycloakClient()
const user = await keycloak.users.findOne({ id: decoded.userId })
if (!user || !user.attributes)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'USER_NOT_FOUND',
})
const passwordResetTokenVersion =
parseInt(user.attributes.passwordResetTokenVersion?.[0]) || null
if (!passwordResetTokenVersion) {
console.error(
`User ${user.id} has no passwordResetTokenVersion attribute`
)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
})
}
if (decoded.tokenVersion !== passwordResetTokenVersion)
throw new TRPCError({
code: 'FORBIDDEN',
message: 'INVALID_TOKEN',
})
await keycloak.users.update(
{ id: decoded.userId },
{
email: decoded.email,
credentials: [
{ type: 'password', value: input.password, temporary: false },
],
attributes: {
passwordResetTokenVersion: (
passwordResetTokenVersion + 1
).toString(),
},
}
)
return { email: decoded.email }
}),
})

View File

@@ -4,7 +4,33 @@ import { initTRPC } from '@trpc/server'
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create()
const t = initTRPC.create({
errorFormatter: ({ error, shape }) => {
if (error.code === 'INTERNAL_SERVER_ERROR') {
if (shape.data.stack) console.error(error)
return {
message: 'Internal server error',
code: shape.code,
data: {
code: shape.data.code,
httpStatus: shape.data.httpStatus,
path: shape.data.path,
},
}
}
return {
message: shape.message,
code: shape.code,
data: {
code: shape.data.code,
httpStatus: shape.data.httpStatus,
path: shape.data.path,
},
}
},
})
// Base router and procedure helpers
export const router = t.router