Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
7 changes: 7 additions & 0 deletions examples/erp/drizzlify/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export const baseConfig = defineBaseConfig({
db: db,
schema: schema,
auth: {
resetPassword: {
enabled: true,
async sendEmailResetPassword(email, token) {
console.log('sendEmailResetPassword config', email, token)
return
},
},
user: {
model: schema.user,
},
Expand Down
4 changes: 1 addition & 3 deletions packages/next/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ['@phosphor-icons/react'],
},
experimental: {},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm why?

}

export default nextConfig
2 changes: 1 addition & 1 deletion packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"zod": "3.25.53"
"tailwindcss": "^4.1.3"
},
"devDependencies": {
"@types/react": "^19.1.6",
Expand Down
14 changes: 12 additions & 2 deletions packages/next/src/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
CollectionLayout,
type Context,
CreateView,
ForgotPasswordView,
HomeView,
ListView,
LoginView,
OneView,
ResetPasswordView,
type ServerConfig,
type ServerFunction,
SignUpView,
Expand Down Expand Up @@ -162,14 +164,22 @@ export function defineNextJsServerConfig<
params: { slug: string; identifier: string }
serverConfig: ServerConfig
searchParams: { [key: string]: string | string[] }
}) => <AuthLayout serverConfig={args.serverConfig}>TODO</AuthLayout>,
}) => (
<AuthLayout serverConfig={args.serverConfig}>
<ForgotPasswordView {...args} {...args.params} />
</AuthLayout>
),
})
radixRouter.insert(`/auth/reset-password`, {
requiredAuthentication: false,
view: (args: {
serverConfig: ServerConfig
searchParams: { [key: string]: string | string[] }
}) => <AuthLayout serverConfig={args.serverConfig}>TODO</AuthLayout>,
}) => (
<AuthLayout serverConfig={args.serverConfig}>
<ResetPasswordView {...args} />
</AuthLayout>
),
})

return {
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.3",
"remeda": "^2.21.2",
"sonner": "^2.0.3",
"tailwind-merge": "^3.0.2",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.1.7",
Expand Down
16 changes: 15 additions & 1 deletion packages/react/src/auth/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AnyTable, Column } from 'drizzle-orm'
import { and, asc, desc, eq } from 'drizzle-orm'
import { and, asc, desc, eq, like } from 'drizzle-orm'
import type { UndefinedToOptional } from 'type-fest/source/internal'

import type { AnyAccountTable, AnySessionTable, AnyUserTable, AuthConfig } from '.'
Expand Down Expand Up @@ -75,6 +75,7 @@ function createInternalHandlers<TAuthConfig extends AuthConfig>(
},
findByEmail: async (email: string) => {
const table = config.user.model

const users = await context.db.select().from(table).where(eq(table.email, email))
if (users.length === 0) throw new Error('User not found')
if (users.length > 1) throw new Error('Multiple users found')
Expand Down Expand Up @@ -206,6 +207,19 @@ function createInternalHandlers<TAuthConfig extends AuthConfig>(
if (verifications.length > 1) throw new Error('Multiple verifications found')
return verifications[0]
},
delete: async (id: string) => {
const table = config.verification.model
const verification = await context.db.delete(table).where(eq(table.id, id)).returning()
if (verification.length === 0) throw new Error('Verification not found')
return verification[0]
},
deleteByUserIdAndIdentifierPrefix: async (userId: string, identifierPrefix: string) => {
const table = config.verification.model
return await context.db
.delete(table)
.where(and(eq(table.value, userId), like(table.identifier, `${identifierPrefix}%`)))
.returning()
},
}

return {
Expand Down
7 changes: 5 additions & 2 deletions packages/react/src/auth/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { forgotPasswordEmail } from './forgot-password'
import { loginEmail } from './login-email'
import { me } from './me'
import { resetPasswordEmail } from './reset-password'
import { resetPasswordEmail, validateResetToken } from './reset-password'
import { sendEmailResetPassword } from './send-email-reset-password'
import { signOut } from './sign-out'
import { signUp } from './sign-up'

Expand All @@ -16,8 +17,10 @@ export function createAuthHandlers<TAuthContext extends AuthContext, TContext ex
signUp: signUp<TAuthContext, TContext>(authContext),
loginEmail: loginEmail<TAuthContext, TContext>(authContext),
signOut: signOut<TAuthContext, TContext>(authContext),
resetPasswordEmail: resetPasswordEmail<TAuthContext, TContext>(authContext),
forgotPasswordEmail: forgotPasswordEmail<TAuthContext, TContext>(authContext),
resetPasswordEmail: resetPasswordEmail<TAuthContext, TContext>(authContext),
validateResetToken: validateResetToken<TAuthContext, TContext>(authContext),
sendEmailResetPassword: sendEmailResetPassword<TAuthContext, TContext>(authContext),
// Authentication required
me: me<TAuthContext, TContext>(authContext),
} as const
Expand Down
80 changes: 78 additions & 2 deletions packages/react/src/auth/handlers/reset-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import z from 'zod/v4'
import type { Context } from '../../core/context'
import { type ApiRouteHandler, type ApiRouteSchema, createEndpoint } from '../../core/endpoint'
import { type AuthContext } from '../context'
import { hashPassword } from '../utils'

export function resetPasswordEmail<
const TAuthContext extends AuthContext,
Expand Down Expand Up @@ -40,7 +41,7 @@ export function resetPasswordEmail<
const identifier = `reset-password:${args.query.token}`
const verification = await internalHandlers.verification.findByIdentifier(identifier)

if (!verification.value) {
if (!verification || !verification.value) {
return {
status: 400,
body: { status: 'invalid reset password value' },
Expand All @@ -49,7 +50,24 @@ export function resetPasswordEmail<

const user = await internalHandlers.user.findById(verification.value)

const hashedPassword = args.body.password // TODO: hash password
if (!user) {
return {
status: 400,
body: { status: 'user not found' },
}
}

if (verification.expiresAt < new Date()) {
return {
status: 400,
body: { status: 'reset password token expired' },
}
}

// delete the verification token
await internalHandlers.verification.delete(verification.id)

const hashedPassword = await hashPassword(args.body.password)
await internalHandlers.account.updatePassword(user.id, hashedPassword)

const redirectTo = `${authConfig.resetPassword?.redirectTo ?? '/auth/login'}`
Expand All @@ -67,3 +85,61 @@ export function resetPasswordEmail<

return createEndpoint(schema, handler)
}

export function validateResetToken<
const TAuthContext extends AuthContext,
const TContext extends Context,
>(authContext: TAuthContext) {
const { internalHandlers } = authContext

const schema = {
method: 'POST',
path: '/api/auth/validate-reset-password-token',
body: z.object({
token: z.string(),
}),
responses: {
200: z.object({
verification: z
.object({
id: z.string(),
identifier: z.string(),
value: z.string().nullable(),
expiresAt: z.string(),
})
.nullable(),
}),
},
} as const satisfies ApiRouteSchema

const handler: ApiRouteHandler<TContext, typeof schema> = async (args) => {
try {
const verification = await internalHandlers.verification.findByIdentifier(
`reset-password:${args.body.token}`
)

if (!verification || verification.expiresAt < new Date()) {
return {
status: 200,
body: { verification: null },
}
}

return {
status: 200,
body: {
verification: verification
? { ...verification, expiresAt: verification.expiresAt.toISOString() }
: null,
},
}
} catch {
return {
status: 200,
body: { verification: null },
}
}
}

return createEndpoint(schema, handler)
}
86 changes: 86 additions & 0 deletions packages/react/src/auth/handlers/send-email-reset-password.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import z from 'zod/v4'

import type { ApiRouteHandler, ApiRouteSchema, Context } from '../../core'
import { createEndpoint } from '../../core/endpoint'
import { type AuthContext } from '../context'

export function sendEmailResetPassword<
const TAuthContext extends AuthContext,
const TContext extends Context,
>(authContext: TAuthContext) {
const { authConfig, internalHandlers } = authContext

const schema = {
method: 'POST',
path: '/api/auth/send-otp-forgot-password',
body: z.object({
email: z.string(),
}),
responses: {
200: z.object({
status: z.string(),
}),
400: z.object({
status: z.string(),
}),
},
} as const satisfies ApiRouteSchema

const handler: ApiRouteHandler<TContext, typeof schema> = async (args) => {
console.log('sendEmailResetPassword called with args:', args)
if (!authConfig.resetPassword?.enabled) {
// TODO: Log not enabled
return {
status: 400,
body: { status: 'reset password not enabled' },
}
}
let user
try {
console.log('Finding user by email:', args.body.email)
user = await internalHandlers.user.findByEmail(args.body.email)
} catch {
return {
status: 400,
body: { status: 'user not found' },
}
}

// Generate a secure random token
const token = crypto.randomUUID()
const identifier = `reset-password:${token}`
await internalHandlers.verification.deleteByUserIdAndIdentifierPrefix(
user.id,
'reset-password:'
)

await internalHandlers.verification.create({
identifier,
value: user.id,
expiresAt: new Date(
Date.now() + (authConfig.resetPassword?.expiresInMs ?? 1000 * 60 * 60 * 24) // TODO; make config always set default
),
})

// TODO: change this to domain config websiteURL
const resetPasswordLink = `${authConfig.resetPassword?.resetPasswordUrl ?? 'http://localhost:3000/admin/auth/reset-password'}?token=${token}`
// Send email
if (authConfig.resetPassword?.sendEmailResetPassword) {
await authConfig.resetPassword.sendEmailResetPassword(user.email, resetPasswordLink)
} else {
// Fallback to console log for development
console.warn(
'No "auth.resetPassword.sendEmailResetPassword" function provided, using console.log for development purposes.'
)
}

return {
status: 200,
body: {
status: 'ok',
},
}
}

return createEndpoint(schema, handler)
}
1 change: 1 addition & 0 deletions packages/react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface AuthConfig {
expiresInMs?: number // default: 1 day (1000 * 60 * 60 * 24)
resetPasswordUrl?: string // default: `/auth/reset-password`
redirectTo?: string // default: `/auth/login`
sendEmailResetPassword: (email: string, token: string) => Promise<void>
}
ui?: {
login?: {
Expand Down
30 changes: 30 additions & 0 deletions packages/react/src/react/components/primitives/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client'

import { Toaster as ToasterPrimitive, type ToasterProps } from 'sonner'

import { useTheme } from '../../providers/theme'

const Toast = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<ToasterPrimitive
theme={theme as ToasterProps['theme']}
className="toaster group"
richColors
toastOptions={{
classNames: {
toast: 'toast border-0! inset-ring! inset-ring-fg/10!',
title: 'title',
description: 'description',
actionButton: 'bg-primary! hover:bg-primary/90! text-primary-fg!',
cancelButton: 'bg-transparent! hover:bg-secondary! hover:text-secondary-fg!',
closeButton: 'close-button',
},
}}
{...props}
/>
)
}

export type { ToasterProps }
export { Toast }
13 changes: 0 additions & 13 deletions packages/react/src/react/pages/auth/_components/left-panel.tsx

This file was deleted.

This file was deleted.

Loading
Loading