Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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.users,
},
Expand Down
15 changes: 14 additions & 1 deletion packages/core/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 @@ -206,6 +206,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: 4 additions & 3 deletions packages/core/src/auth/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 @@ -14,7 +14,8 @@ export function createAuthHandlers<TAuthConfig extends AuthConfig>(config: TAuth
loginEmail: loginEmail(config),
signOut: signOut({}),
resetPasswordEmail: resetPasswordEmail({}),
forgotPasswordEmail: forgotPasswordEmail({}),
sendEmailResetPassword: sendEmailResetPassword({}),
validateResetToken: validateResetToken({}),
// Authentication required
me: me({}),
} as const
Expand Down
57 changes: 54 additions & 3 deletions packages/core/src/auth/handlers/reset-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

import { type ApiRouteHandler, type ApiRouteSchema, createEndpoint } from '../../endpoint'
import { type AuthContext } from '../context'
import { hashPassword } from '../utils'

interface InternalRouteOptions {
prefix?: string
}

export function resetPasswordEmail<const TOptions extends InternalRouteOptions>(options: TOptions) {

Check warning on line 11 in packages/core/src/auth/handlers/reset-password.ts

View workflow job for this annotation

GitHub Actions / ci

'options' is defined but never used. Allowed unused args must match /^_/u
const schema = {
method: 'POST',
path: '/api/auth/reset-password',
Expand Down Expand Up @@ -40,7 +41,7 @@
const verification =
await args.context.internalHandlers.verification.findByIdentifier(identifier)

if (!verification.value) {
if (!verification || !verification.value) {
return {
status: 400,
body: { status: 'invalid reset password value' },
Expand All @@ -49,10 +50,27 @@

const user = await args.context.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 args.context.internalHandlers.verification.delete(verification.id)

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

const redirectTo = `${args.context.authConfig.resetPassword?.redirectTo ?? '/auth/login'}`
const redirectTo = `${args.context.authConfig.resetPassword?.redirectTo ?? '/admin/auth/login'}`
Comment thread
masternonnolnw marked this conversation as resolved.
Outdated

const responseHeaders = {
Location: redirectTo,
Expand All @@ -67,3 +85,36 @@

return createEndpoint(schema, handler)
}

export function validateResetToken<const TOptions extends InternalRouteOptions>(options: TOptions) {

Check warning on line 89 in packages/core/src/auth/handlers/reset-password.ts

View workflow job for this annotation

GitHub Actions / ci

'options' is defined but never used. Allowed unused args must match /^_/u
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.date(),
}),
}),
},
} as const satisfies ApiRouteSchema

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

return {
status: 200,
body: { verification },
}
}

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

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

interface InternalRouteOptions {
prefix?: string
}

export function sendEmailResetPassword<const TOptions extends InternalRouteOptions>(
options: TOptions

Check warning on line 11 in packages/core/src/auth/handlers/send-email-reset-password.ts

View workflow job for this annotation

GitHub Actions / ci

'options' is defined but never used. Allowed unused args must match /^_/u
) {
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<AuthContext, typeof schema> = async (args) => {
if (!args.context.authConfig.resetPassword?.enabled) {
// TODO: Log not enabled
return {
status: 400,
body: { status: 'reset password not enabled' },
}
}
let user
try {
user = await args.context.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 args.context.internalHandlers.verification.deleteByUserIdAndIdentifierPrefix(
user.id,
'reset-password:'
)

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

// TODO: change this to domain config websiteURL
const resetPasswordLink = `${args.context.authConfig.resetPassword?.resetPasswordUrl ?? 'http://localhost:3000/admin/auth/reset-password'}?token=${token}`
// Send email
if (args.context.authConfig.resetPassword?.sendEmailResetPassword) {
await args.context.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/core/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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
5 changes: 3 additions & 2 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@intentui/icons": "^1.10.31",
"@intentui/icons": "^1.11.0",
Comment thread
masternonnolnw marked this conversation as resolved.
Outdated
"@kivotos/core": "workspace:^",
"@phosphor-icons/react": "^2.1.8",
"@radix-ui/react-slot": "^1.2.0",
Expand All @@ -36,11 +36,12 @@
"next": "15.2.2",
"radix3": "^1.1.2",
"react": "^19.1.0",
"react-aria-components": "^1.7.1",
"react-aria-components": "^1.8.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"react-router": "^7.4.1",
"remeda": "^2.21.2",
"sonner": "^2.0.3",
"tailwindcss": "^4.1.3",
"tailwindcss-react-aria-components": "^2.0.0",
"valibot": "^1.0.0",
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 @@ -12,8 +12,10 @@ import type {

import { createApiResourceRouter } from './resource'
import type { ServerFunction } from './server-function'
import { ForgotPasswordView } from './views/auth/forgot-password/forgot-password'
import { AuthLayout } from './views/auth/layout'
import { LoginView } from './views/auth/login'
import { ResetPasswordView } from './views/auth/reset-password/reset-password'
import { SignUpView } from './views/auth/sign-up'
import { CreateView } from './views/collections/create'
import { HomeView } from './views/collections/home'
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
30 changes: 30 additions & 0 deletions packages/next/src/intentui/ui/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 '../theme-provider'

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 }
2 changes: 2 additions & 0 deletions packages/next/src/providers/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createContext, type ReactNode, useContext } from 'react'

import type { ClientConfig, Collection, ServerConfig } from '@kivotos/core'

import { Toast } from '../intentui/ui/toast'
import type { ServerFunction } from '../server-function'

type RootContextValue<TServerConfig extends ServerConfig = ServerConfig> = {
Expand Down Expand Up @@ -52,6 +53,7 @@ export const RootProvider = (props: {
serverFunction: props.serverFunction,
}}
>
<Toast />
{props.children}
</RootContext.Provider>
)
Expand Down
Loading
Loading