Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/blue-mirrors-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@genseki/plugins": minor
---

Implement self-managed PIN code via `onOtpSent` in Phone Plugin
26 changes: 13 additions & 13 deletions examples/erp/genseki/plugins/phone.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import z from 'zod'

import { phone, PhoneService, PhoneStore } from '@genseki/plugins'
import { generatePinCode, generateRefCode, phone, PhoneService, PhoneStore } from '@genseki/plugins'

import { FullModelSchemas } from '../../generated/genseki/unsanitized'
import { context, prisma } from '../helper'
Expand Down Expand Up @@ -39,36 +39,36 @@ const phoneService = new PhoneService(
signUp: {
body: body,
onOtpSent: async () => {
const pin = generatePinCode()
console.log('PIN CODE', pin) // In real world, you should send this pin code to user via SMS
return {
refCode: crypto.randomUUID().slice(0, 6),
refCode: generateRefCode(),
token: crypto.randomUUID(),
pin,
}
},
onOtpVerify: async (payload) => {
return Math.random() < 0.5
},
},
changePhone: {
onOtpSent: async () => {
const pin = generatePinCode()
console.log('PIN CODE', pin) // In real world, you should send this pin code to user via SMS
return {
refCode: crypto.randomUUID().slice(0, 6),
refCode: generateRefCode(),
token: crypto.randomUUID(),
pin,
}
},
onOtpVerify: async (payload) => {
return Math.random() < 0.5
},
},
forgotPassword: {
onOtpSent: async () => {
const pin = generatePinCode()
console.log('PIN CODE', pin) // In real world, you should send this pin code to user via SMS
return {
refCode: crypto.randomUUID().slice(0, 6),
refCode: generateRefCode(),
token: crypto.randomUUID(),
pin,
}
},
onOtpVerify: async (payload) => {
return Math.random() < 0.5
},
},
},
phoneStore
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/src/phone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type { PhoneService } from './service'
import type { PhoneStore } from './store'
import type { PluginSchema } from './types'

export { generatePinCode, generateRefCode } from './utils'

export function phone<
TContext extends AnyContextable,
TPhoneService extends PhoneService<
Expand Down
165 changes: 95 additions & 70 deletions packages/plugins/src/phone/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,32 @@ import {
type ValidateSchema,
} from '@genseki/react'

import type { PhoneStore } from './store'
import type {
ChangePhoneNumberVerificationPayload,
ForgotPasswordVerificationPayload,
PhoneStore,
SignUpVerificationPayload,
} from './store'
import type { BaseSignUpBodySchema, PluginSchema } from './types'

interface OnOtpSentArgs {
phone: string
name: string | undefined | null
}

interface OnOtpSentReturn {
token: string
refCode: string
pin?: string // Provided when using manual OTP handling
}

interface OnOtpVerification<T> {
phone: string
token: string
pin: string
verification: { id: string; value: T; createdAt: Date; expiredAt: Date }
}

interface PhoneServiceOptions<
TSignUpBodySchema extends BaseSignUpBodySchema = BaseSignUpBodySchema,
> {
Expand All @@ -22,66 +46,66 @@ interface PhoneServiceOptions<
verifyPassword?: (password: string, hashed: string) => Promise<boolean>
}
signUp: {
body: TSignUpBodySchema
autoLogin?: boolean // default to true
onOtpSent: (data: {
phone: string
name: string
refCode: string
}) => Promise<{ token: string; refCode: string }>
onOtpVerify: (data: { phone: string; token: string; pin: string }) => Promise<boolean>
body: TSignUpBodySchema
onOtpSent: (data: OnOtpSentArgs) => Promise<OnOtpSentReturn>
onOtpVerify?: (
data: OnOtpVerification<SignUpVerificationPayload<z.output<TSignUpBodySchema>>>
) => Promise<boolean>
}
changePhone?: {
onOtpSent: (data: {
phone: string
name: string | undefined | null
refCode: string
}) => Promise<{ token: string; refCode: string }>
onOtpVerify: (data: { phone: string; token: string; pin: string }) => Promise<boolean>
onOtpSent: (data: OnOtpSentArgs) => Promise<OnOtpSentReturn>
onOtpVerify?: (
data: OnOtpVerification<ChangePhoneNumberVerificationPayload>
) => Promise<boolean>
}
forgotPassword?: {
onOtpSent: (data: {
phone: string
name: string | undefined | null
refCode: string
}) => Promise<{ token: string; refCode: string }>
onOtpVerify: (data: { phone: string; token: string; pin: string }) => Promise<boolean>
onOtpSent: (data: OnOtpSentArgs) => Promise<OnOtpSentReturn>
onOtpVerify?: (data: OnOtpVerification<ForgotPasswordVerificationPayload>) => Promise<boolean>
}
}

const defaultOptions = {
login: {
sessionExpiredInSeconds: 3600,
hashPassword: Password.hashPassword,
verifyPassword: Password.verifyPassword,
},
} satisfies PartialDeep<PhoneServiceOptions>

export type PhoneServiceOptionsWithDefaults<
TSignUpBodySchema extends BaseSignUpBodySchema = BaseSignUpBodySchema,
> = ReturnType<typeof defu<PhoneServiceOptions<TSignUpBodySchema>, [typeof defaultOptions]>>

export class PhoneService<
TContext extends AnyContextable,
TSchema extends ObjectWithOnlyValue<PluginSchema, any>,
TSignUpBodySchema extends BaseSignUpBodySchema,
TStore extends PhoneStore<TSignUpBodySchema>,
> {
private readonly options: PhoneServiceOptionsWithDefaults<TSignUpBodySchema>
private readonly options: PhoneServiceOptions<TSignUpBodySchema>

constructor(
public readonly context: TContext,
public readonly schema: TSchema,
options: ValidateSchema<PluginSchema, TSchema, PhoneServiceOptions<TSignUpBodySchema>>,
private readonly store: TStore
) {
this.options = defu(
options,
defaultOptions
) as PhoneServiceOptionsWithDefaults<TSignUpBodySchema>
const defaultOptions = {
login: {
sessionExpiredInSeconds: 3600,
hashPassword: Password.hashPassword,
verifyPassword: Password.verifyPassword,
},
signUp: {
onOtpVerify: async (data) => {
return data.pin === data.verification.value.pin
},
},
changePhone: {
onOtpVerify: async (data) => {
return data.pin === data.verification.value.pin
},
},
forgotPassword: {
onOtpVerify: async (data) => {
return data.pin === data.verification.value.pin
},
},
} satisfies PartialDeep<PhoneServiceOptions>

this.options = defu(options, defaultOptions) as PhoneServiceOptions<TSignUpBodySchema>
}

getOptions(): PhoneServiceOptionsWithDefaults<TSignUpBodySchema> {
getOptions(): PhoneServiceOptions<TSignUpBodySchema> {
return this.options
}

Expand All @@ -106,10 +130,17 @@ export class PhoneService<
return err({ message: 'Account not found or password not set' })
}

const verifyStatus = await Password.verifyPassword(
// NOTE: verifyPassword function is default from options, can be customized
const verifyStatus = await this.options.login!.verifyPassword!(
body.password,
credentialAccount.password as string
)
.then((result) => ok(result))
.catch((error) => err(error))

if (verifyStatus.isErr()) {
return err({ message: 'Internal Server Error', cause: verifyStatus.error })
}

if (!verifyStatus) {
return err({ message: 'Invalid password' })
Expand Down Expand Up @@ -154,11 +185,7 @@ export class PhoneService<

// Send phone verification OTP
const otpResponse = await this.options.signUp
.onOtpSent({
phone: data.phone,
name: data.name,
refCode: await this.store.generateRefCode(),
})
.onOtpSent({ phone: data.phone, name: data.name })
.then((result) => ok(result))
.catch((error) => err(error))

Expand All @@ -170,7 +197,8 @@ export class PhoneService<
})
}

const hashedPassword = await this.options.login.hashPassword(data.password)
// NOTE: Hash function is default from options, can be customized
const hashedPassword = await this.options.login!.hashPassword!(data.password)

const { password: _, ...rest } = data

Expand All @@ -183,6 +211,7 @@ export class PhoneService<
password: hashedPassword,
data: rest as z.output<TSignUpBodySchema>,
attempt: 0,
pin: otpResponse.value.pin,
},
// TODO: Check the expiration time from config
expiredAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
Expand Down Expand Up @@ -224,12 +253,13 @@ export class PhoneService<
})
}

const verifyStatus = await this.options.signUp
.onOtpVerify({
phone: data.phone,
token: verification.id,
pin: data.pin,
})
// NOTE: signUp.onOtpVerify function is default from options, can be customized
const verifyStatus = await this.options.signUp!.onOtpVerify!({
phone: data.phone,
token: verification.id,
pin: data.pin,
verification,
})
.then((result) => ok(result))
.catch((error) => err(error))

Expand Down Expand Up @@ -317,14 +347,8 @@ export class PhoneService<
})
}

const refCode = await this.store.generateRefCode()

const otpResponse = await this.options.changePhone
.onOtpSent({
phone: phone.new,
name: existingUser.name,
refCode: refCode,
})
.onOtpSent({ phone: phone.new, name: existingUser.name })
.then((result) => ok(result))
.catch((error) => err(error))

Expand All @@ -344,6 +368,7 @@ export class PhoneService<
oldPhone: phone.old,
newPhone: phone.new,
attempt: 0,
pin: otpResponse.value.pin,
},
// TODO: Customizable
expiredAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
Expand Down Expand Up @@ -396,6 +421,7 @@ export class PhoneService<
phone: verification.value.newPhone,
pin: payload.pin,
token: verification.id,
verification,
})
.then((result) => ok(result))
.catch((error) => err(error))
Expand Down Expand Up @@ -464,11 +490,7 @@ export class PhoneService<
}

const otpResponse = await this.options.forgotPassword
.onOtpSent({
phone: phone,
name: existingUser.name,
refCode: await this.store.generateRefCode(),
})
.onOtpSent({ phone: phone, name: existingUser.name })
.then((result) => ok(result))
.catch((error) => err(error))

Expand All @@ -488,6 +510,7 @@ export class PhoneService<
phone: phone,
attempt: 0,
userId: existingUser.id,
pin: otpResponse.value.pin,
},
// TODO: Customizable
expiredAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
Expand Down Expand Up @@ -542,12 +565,13 @@ export class PhoneService<
})
}

const verifyStatus = await this.options.forgotPassword
.onOtpVerify({
phone: payload.phone,
pin: payload.pin,
token: verification.id,
})
// NOTE: forgotPassword.onOtpVerify function is default from options, can be customized
const verifyStatus = await this.options.forgotPassword!.onOtpVerify!({
phone: payload.phone,
pin: payload.pin,
token: verification.id,
verification,
})
.then((result) => ok(result))
.catch((error) => err(error))

Expand Down Expand Up @@ -608,7 +632,8 @@ export class PhoneService<
})
}

const hashedPassword = await Password.hashPassword(payload.password)
// NOTE: Hash function is default from options, can be customized
const hashedPassword = await this.options.login!.hashPassword!(payload.password)
.then((result) => ok(result))
.catch((error) => err(error))

Expand Down
Loading
Loading